diff --git a/music_assistant/providers/nicovideo/__init__.py b/music_assistant/providers/nicovideo/__init__.py new file mode 100644 index 0000000000..35f84f0b69 --- /dev/null +++ b/music_assistant/providers/nicovideo/__init__.py @@ -0,0 +1,53 @@ +"""nicovideo support for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ProviderFeature + +from music_assistant.mass import MusicAssistant +from music_assistant.models import ProviderInstanceType +from music_assistant.providers.nicovideo.config import get_config_entries_impl +from music_assistant.providers.nicovideo.provider import NicovideoMusicProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, + ) + from music_assistant_models.provider import ProviderManifest + +# Supported features collected from all mixins +SUPPORTED_FEATURES = { + # Artist mixin + ProviderFeature.ARTIST_TOPTRACKS, + ProviderFeature.ARTIST_ALBUMS, + ProviderFeature.LIBRARY_ARTISTS, + # Playlist mixin + ProviderFeature.LIBRARY_PLAYLISTS, + ProviderFeature.PLAYLIST_TRACKS_EDIT, + ProviderFeature.PLAYLIST_CREATE, + # Explorer mixin + ProviderFeature.SEARCH, + ProviderFeature.RECOMMENDATIONS, + ProviderFeature.SIMILAR_TRACKS, +} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return NicovideoMusicProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +async def get_config_entries( + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 +) -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider.""" + return await get_config_entries_impl() diff --git a/music_assistant/providers/nicovideo/config/__init__.py b/music_assistant/providers/nicovideo/config/__init__.py new file mode 100644 index 0000000000..367b24a9ed --- /dev/null +++ b/music_assistant/providers/nicovideo/config/__init__.py @@ -0,0 +1,25 @@ +"""Nicovideo provider configuration system.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .categories import AuthConfigCategory +from .factory import get_config_entries_impl + +if TYPE_CHECKING: + from music_assistant.models.provider import Provider + + +class NicovideoConfig: + """Configuration system for Nicovideo provider.""" + + def __init__(self, provider: Provider) -> None: + """Initialize with all category instances.""" + self.auth = AuthConfigCategory(provider) + + +__all__ = [ + "NicovideoConfig", + "get_config_entries_impl", +] diff --git a/music_assistant/providers/nicovideo/config/categories/__init__.py b/music_assistant/providers/nicovideo/config/categories/__init__.py new file mode 100644 index 0000000000..fed5bc8405 --- /dev/null +++ b/music_assistant/providers/nicovideo/config/categories/__init__.py @@ -0,0 +1,9 @@ +"""Configuration categories for Nicovideo provider.""" + +from .auth import AuthConfigCategory +from .base import ConfigCategoryBase + +__all__ = [ + "AuthConfigCategory", + "ConfigCategoryBase", +] diff --git a/music_assistant/providers/nicovideo/config/categories/auth.py b/music_assistant/providers/nicovideo/config/categories/auth.py new file mode 100644 index 0000000000..3ba0a0a37e --- /dev/null +++ b/music_assistant/providers/nicovideo/config/categories/auth.py @@ -0,0 +1,59 @@ +"""Authentication configuration category for Nicovideo provider.""" + +from __future__ import annotations + +from music_assistant.providers.nicovideo.config.categories.base import ConfigCategoryBase +from music_assistant.providers.nicovideo.config.factory import ConfigFactory + + +class AuthConfigCategory(ConfigCategoryBase): + """Authentication settings category.""" + + _auth = ConfigFactory("Authentication") + + mail = _auth.str_config( + key="mail", + label="Email", + default=None, + description="Your NicoNico account email address.", + ) + + password = _auth.secure_str_or_none_config( + key="password", + label="Password", + description="Your NicoNico account password.", + ) + + mfa = _auth.str_config( + key="mfa", + label="MFA Code (One-Time Password)", + default=None, + description="Enter the 6-digit confirmation code from your 2-step verification app.", + ) + + user_session = _auth.secure_str_or_none_config( + key="user_session", + label="User Session ( 'user_session' in Cookie)", + description=( + "Enter the user_session cookie value.\n" + "If invalid, it will be automatically set from your email and password." + ), + ) + + def save_user_session(self, value: str) -> None: + """Save user session to config.""" + self.writer.set_raw_provider_config_value( + self.provider.instance_id, + "user_session", + value, + True, + ) + + def clear_mfa_code(self) -> None: + """Clear MFA code after successful use (one-time password should not be reused).""" + self.writer.set_raw_provider_config_value( + self.provider.instance_id, + "mfa", + None, + True, + ) diff --git a/music_assistant/providers/nicovideo/config/categories/base.py b/music_assistant/providers/nicovideo/config/categories/base.py new file mode 100644 index 0000000000..7db35629cf --- /dev/null +++ b/music_assistant/providers/nicovideo/config/categories/base.py @@ -0,0 +1,36 @@ +"""Base class for configuration categories.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from music_assistant.controllers.config import ConfigController +from music_assistant.providers.nicovideo.config.descriptor import ConfigReader + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + + from music_assistant.models.provider import Provider + + +class ConfigCategoryBase(ConfigReader): + """Base class for config categories.""" + + def __init__(self, provider: Provider) -> None: + """Initialize category with provider instance.""" + self.provider = provider + + @property + def reader(self) -> ProviderConfig: + """Get the config reader interface.""" + return self.provider.config + + @property + def writer(self) -> ConfigController: + """Get the config writer interface.""" + return self.provider.mass.config + + @override + def get_value(self, key: str) -> ConfigValueType: + """Get config value from provider.""" + return self.reader.get_value(key) diff --git a/music_assistant/providers/nicovideo/config/descriptor.py b/music_assistant/providers/nicovideo/config/descriptor.py new file mode 100644 index 0000000000..a12b4f01c5 --- /dev/null +++ b/music_assistant/providers/nicovideo/config/descriptor.py @@ -0,0 +1,45 @@ +"""Configuration descriptor implementation for Nicovideo provider.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType + + +class ConfigReader(Protocol): + """Protocol for configuration readers.""" + + def get_value(self, key: str) -> ConfigValueType: + """Retrieve a configuration value by key.""" + ... + + +class ConfigDescriptor[T]: + """Typed config descriptor with embedded ConfigEntry.""" + + def __init__( + self, + cast: Callable[[ConfigValueType], T], + config_entry: ConfigEntry, + ) -> None: + """Initialize descriptor. + + Args: + cast: Transformation/validation applied to raw value. + config_entry: ConfigEntry definition for this option. + """ + self.cast = cast + self.config_entry = config_entry + + @property + def key(self) -> str: + """Get the config key from the embedded ConfigEntry.""" + return self.config_entry.key + + def __get__(self, instance: ConfigReader, owner: type) -> T: + """Descriptor access.""" + raw = instance.get_value(self.key) + return self.cast(raw) diff --git a/music_assistant/providers/nicovideo/config/factory.py b/music_assistant/providers/nicovideo/config/factory.py new file mode 100644 index 0000000000..51d56c5936 --- /dev/null +++ b/music_assistant/providers/nicovideo/config/factory.py @@ -0,0 +1,198 @@ +"""Configuration factory for creating typed config descriptors.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import overload + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueType +from music_assistant_models.enums import ConfigEntryType + +from .descriptor import ConfigDescriptor + +# Global registry for all config entries +_registry: list[ConfigEntry] = [] + + +class ConfigFactory: + """Factory class for creating config options with automatic category assignment.""" + + def __init__(self, category: str) -> None: + """Initialize factory with a specific category name.""" + self.category = category + + def bool_config( + self, + key: str, + label: str, + default: bool = False, + description: str = "", + ) -> ConfigDescriptor[bool]: + """Create boolean config options.""" + return ConfigDescriptor( + cast=ConfigFactory.as_bool(default), + config_entry=self._create_entry( + key=key, + entry_type=ConfigEntryType.BOOLEAN, + label=label, + default_value=default, + description=description, + ), + ) + + def int_config( + self, + key: str, + label: str, + default: int = 25, + min_val: int = 1, + max_val: int = 100, + description: str = "", + ) -> ConfigDescriptor[int]: + """Create integer config options.""" + return ConfigDescriptor( + cast=ConfigFactory.as_int(default, min_val, max_val), + config_entry=self._create_entry( + key=key, + entry_type=ConfigEntryType.INTEGER, + label=label, + default_value=default, + description=description, + value_range=(min_val, max_val), + ), + ) + + def str_list_config( + self, key: str, label: str, description: str = "" + ) -> ConfigDescriptor[list[str]]: + """Create string list config options (comma-separated tags).""" + return ConfigDescriptor( + cast=ConfigFactory.as_str_list(), + config_entry=self._create_entry( + key=key, + entry_type=ConfigEntryType.STRING, + label=label, + default_value="", + description=description, + ), + ) + + @overload + def str_config( + self, key: str, label: str, default: str, description: str = "" + ) -> ConfigDescriptor[str]: ... + + @overload + def str_config( + self, key: str, label: str, default: None = None, description: str = "" + ) -> ConfigDescriptor[str | None]: ... + + def str_config( + self, key: str, label: str, default: str | None = None, description: str = "" + ) -> ConfigDescriptor[str] | ConfigDescriptor[str | None]: + """Create string config options that can be None.""" + return ConfigDescriptor( + cast=ConfigFactory.as_str(default), + config_entry=self._create_entry( + key=key, + entry_type=ConfigEntryType.STRING, + label=label, + default_value=default, + description=description, + ), + ) + + def secure_str_or_none_config( + self, key: str, label: str, description: str = "" + ) -> ConfigDescriptor[str | None]: + """Create secure string config options that can be None.""" + return ConfigDescriptor( + cast=ConfigFactory.as_str(None), + config_entry=self._create_entry( + key=key, + entry_type=ConfigEntryType.SECURE_STRING, + label=label, + default_value="", + description=description, + ), + ) + + def _create_entry( + self, + key: str, + entry_type: ConfigEntryType, + label: str, + default_value: ConfigValueType, + description: str, + value_range: tuple[int, int] | None = None, + ) -> ConfigEntry: + """Create and register a ConfigEntry.""" + entry = ConfigEntry( + key=key, + type=entry_type, + label=label, + required=False, + default_value=default_value, + description=description, + category=self.category, + range=value_range, + ) + _registry.append(entry) + return entry + + @classmethod + def as_bool(cls, default: bool = False) -> Callable[[ConfigValueType], bool]: + """Return a caster that converts a raw value to bool with default.""" + + def _cast(v: ConfigValueType) -> bool: + return bool(v) if v is not None else default + + return _cast + + @classmethod + def as_int( + cls, default: int = 0, min_val: int = 1, max_val: int = 100 + ) -> Callable[[ConfigValueType], int]: + """Return a caster that converts a raw value to int with validation and default.""" + + def _cast(v: ConfigValueType) -> int: + if not isinstance(v, int) or v < min_val: + return default + return min(v, max_val) + + return _cast + + @classmethod + @overload + def as_str(cls, default: str) -> Callable[[ConfigValueType], str]: ... + + @classmethod + @overload + def as_str(cls, default: str | None = None) -> Callable[[ConfigValueType], str | None]: ... + + @classmethod + def as_str(cls, default: str | None = None) -> Callable[[ConfigValueType], str | None]: + """Return a caster that converts a raw value to str or None (no default).""" + + def _cast(v: ConfigValueType) -> str | None: + return str(v) if v is not None else default + + return _cast + + @classmethod + def as_str_list(cls) -> Callable[[ConfigValueType], list[str]]: + """Return a caster that converts a raw value to list of strings.""" + + def _cast(v: ConfigValueType) -> list[str]: + if not v or not isinstance(v, str): + return [] + # Split by comma and clean up whitespace + return [tag.strip() for tag in v.split(",") if tag.strip()] + + return _cast + + +async def get_config_entries_impl() -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider.""" + # Combine entries from logical categories + return tuple(_registry) diff --git a/music_assistant/providers/nicovideo/constants.py b/music_assistant/providers/nicovideo/constants.py new file mode 100644 index 0000000000..d723d7d1b0 --- /dev/null +++ b/music_assistant/providers/nicovideo/constants.py @@ -0,0 +1,36 @@ +"""Constants for the nicovideo provider in Music Assistant.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ContentType + +if TYPE_CHECKING: + from typing import Literal + + +class ApiPriority(Enum): + """Priority levels for nicovideo API calls.""" + + HIGH = "high" + LOW = "low" + + +# Network constants +NICOVIDEO_USER_AGENT = "Music Assistant/1.0" +DOMAND_BID_COOKIE_NAME = "domand_bid" + +# Audio format constants based on niconico official specifications +# Sources: +# - https://qa.nicovideo.jp/faq/show/21908 +# - https://qa.nicovideo.jp/faq/show/5685 +NICOVIDEO_CONTENT_TYPE = ContentType.MP4 +NICOVIDEO_CODEC_TYPE = ContentType.AAC +NICOVIDEO_AUDIO_CHANNELS = 2 # Stereo (2ch) +NICOVIDEO_AUDIO_BIT_DEPTH = 16 # 16-bit (confirmed from downloaded video analysis) + +# Content filtering constants +# Default behavior for sensitive content handling +SENSITIVE_CONTENTS: Literal["mask", "filter"] = "mask" diff --git a/music_assistant/providers/nicovideo/converters/__init__.py b/music_assistant/providers/nicovideo/converters/__init__.py new file mode 100644 index 0000000000..b47a588775 --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/__init__.py @@ -0,0 +1,15 @@ +""" +Nicovideo converters module. + +Converters Layer: Data transformation +Transforms nicovideo objects into Music Assistant media items using an adapter pattern. +Handles metadata mapping, normalization, and cross-references between items. +""" + +from __future__ import annotations + +from music_assistant.providers.nicovideo.converters.manager import ( + NicovideoConverterManager, +) + +__all__ = ["NicovideoConverterManager"] diff --git a/music_assistant/providers/nicovideo/converters/album.py b/music_assistant/providers/nicovideo/converters/album.py new file mode 100644 index 0000000000..c576222701 --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/album.py @@ -0,0 +1,132 @@ +"""Album converter for nicovideo objects.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ImageType, LinkType +from music_assistant_models.media_items import ( + Album, + Artist, + ItemMapping, + MediaItemImage, + MediaItemLink, + MediaItemMetadata, +) +from music_assistant_models.unique_list import UniqueList +from niconico.objects.nvapi import SeriesData +from niconico.objects.video.search import EssentialSeries +from niconico.objects.video.watch import WatchSeries + +if TYPE_CHECKING: + from niconico.objects.user import UserSeriesItem + +from music_assistant.providers.nicovideo.converters.base import NicovideoConverterBase +from music_assistant.providers.nicovideo.helpers import AlbumWithTracks + + +class NicovideoAlbumConverter(NicovideoConverterBase): + """Handles album conversion for nicovideo.""" + + def convert_by_series( + self, + series: SeriesData | UserSeriesItem | EssentialSeries | WatchSeries, + artists_list: UniqueList[Artist | ItemMapping] | None = None, + ) -> Album: + """Convert a nicovideo SeriesData, UserSeriesItem, or EssentialSeries into an Album.""" + # Extract common data based on series type + if isinstance(series, SeriesData): + item_id = str(series.detail.id_) + name = series.detail.title + description = series.detail.description or "" + thumbnail_url = series.detail.thumbnail_url + series_owner = series.detail.owner + owner_id = series_owner.id_ if series_owner else None + owner_name = None + if series_owner: + if series_owner.type_ == "user" and series_owner.user: + owner_name = series_owner.user.nickname + elif series_owner.type_ == "channel" and series_owner.channel: + owner_name = series_owner.channel.name + elif isinstance(series, WatchSeries): + item_id = str(series.id_) + name = series.title + description = series.description or "" + thumbnail_url = series.thumbnail_url + owner_id = None + owner_name = None + elif isinstance(series, EssentialSeries): + item_id = str(series.id_) + name = series.title + description = series.description or "" + thumbnail_url = series.thumbnail_url + essential_owner = series.owner + owner_id = essential_owner.id_ if essential_owner else None + owner_name = essential_owner.name if essential_owner else None + else: # UserSeriesItem + item_id = str(series.id_) + name = series.title + description = series.description or "" + thumbnail_url = series.thumbnail_url + user_owner = series.owner + owner_id = user_owner.id_ if user_owner else None + owner_name = None # UserSeriesItem doesn't seem to have owner name + + # Create album with common structure + album = Album( + item_id=item_id, + provider=self.provider.lookup_key, + name=name, + metadata=MediaItemMetadata( + description=description, + links={ + MediaItemLink( + type=LinkType.WEBSITE, + url=f"https://www.nicovideo.jp/series/{item_id}", + ) + }, + ), + provider_mappings=self.helper.create_provider_mapping(item_id, "series"), + ) + + # Build artists list from provided artists and/or series owner + artists_out = UniqueList(artists_list) + + if owner_id: + owner_artist = Artist( + item_id=str(owner_id), + provider=self.provider.lookup_key, + name=owner_name if owner_name else "", + provider_mappings=self.helper.create_provider_mapping( + item_id=str(owner_id), + url_path="user", + ), + ) + artists_out.append(owner_artist) + if artists_out: + album.artists = artists_out + + # Add thumbnail image if available (exclude default no-thumbnail image) + if thumbnail_url and not thumbnail_url.endswith("/series/no_thumbnail.png"): + album.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=thumbnail_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ] + ) + + return album + + def convert_series_to_album_with_tracks(self, series_data: SeriesData) -> AlbumWithTracks: + """Convert SeriesData to AlbumWithTracks.""" + album = self.convert_by_series(series_data) + tracks = [] + for item in series_data.items or []: + track = self.converter_manager.track.convert_by_essential_video(item.video) + if track: + tracks.append(track) + return AlbumWithTracks(album, tracks) diff --git a/music_assistant/providers/nicovideo/converters/artist.py b/music_assistant/providers/nicovideo/converters/artist.py new file mode 100644 index 0000000000..d86060eb4f --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/artist.py @@ -0,0 +1,88 @@ +"""Artist converter for nicovideo objects.""" + +from __future__ import annotations + +from music_assistant_models.enums import ImageType, LinkType +from music_assistant_models.media_items import ( + Artist, + MediaItemImage, + MediaItemLink, + MediaItemMetadata, +) +from niconico.objects.user import NicoUser, RelationshipUser +from niconico.objects.video import Owner + +from music_assistant.providers.nicovideo.converters.base import NicovideoConverterBase +from music_assistant.providers.nicovideo.converters.helper import NicovideoUrlPath + + +class NicovideoArtistConverter(NicovideoConverterBase): + """Handles artist conversion for nicovideo.""" + + def convert_by_owner_or_user( + self, owner_or_user: Owner | NicoUser | RelationshipUser + ) -> Artist: + """Convert an Owner, NicoUser, or RelationshipUser into an Artist.""" + item_id = str(owner_or_user.id_) + + # Handle name extraction for different types + if isinstance(owner_or_user, Owner): + name = str(owner_or_user.name) + else: # NicoUser or RelationshipUser + name = str(owner_or_user.nickname) + + # Handle icon URL extraction for different types + if isinstance(owner_or_user, Owner): + icon_url = owner_or_user.icon_url + else: # NicoUser or RelationshipUser + icon_url = owner_or_user.icons.large + + # Determine URL path based on owner type + url_path: NicovideoUrlPath = "user" # Default for users, NicoUser, and RelationshipUser + if isinstance(owner_or_user, Owner) and owner_or_user.owner_type == "channel": + url_path = "channel" + + artist = Artist( + item_id=item_id, + provider=self.provider.lookup_key, + name=name, + metadata=MediaItemMetadata( + description=owner_or_user.description + if isinstance(owner_or_user, (NicoUser, RelationshipUser)) + else None, + ), + provider_mappings=self.helper.create_provider_mapping( + item_id=item_id, + url_path=url_path, + ), + ) + + # Add icon image if available + if icon_url: + artist.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=icon_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ) + + # Add links to artist metadata + artist.metadata.links = { + MediaItemLink( + type=LinkType.WEBSITE, + url=f"https://www.nicovideo.jp/{url_path}/{item_id}", + ) + } + if isinstance(owner_or_user, NicoUser): + # Add SNS links if available + for sns in owner_or_user.sns: + artist.metadata.links.add( + MediaItemLink( + type=LinkType(sns.type_), + url=sns.url, + ) + ) + + return artist diff --git a/music_assistant/providers/nicovideo/converters/base.py b/music_assistant/providers/nicovideo/converters/base.py new file mode 100644 index 0000000000..114be38eed --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/base.py @@ -0,0 +1,31 @@ +"""Base classes for nicovideo converters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from music_assistant.models.music_provider import MusicProvider + from music_assistant.providers.nicovideo.converters.helper import NicovideoConverterHelper + from music_assistant.providers.nicovideo.converters.manager import ( + NicovideoConverterManager, + ) + + +class NicovideoConverterBase: + """Base class for specialized nicovideo converters.""" + + def __init__(self, converter_manager: NicovideoConverterManager) -> None: + """Initialize with reference to main converter.""" + self.converter_manager = converter_manager + self.logger = converter_manager.logger.getChild(self.__class__.__name__) + + @property + def provider(self) -> MusicProvider: + """Get the main converter manager instance.""" + return self.converter_manager.provider + + @property + def helper(self) -> NicovideoConverterHelper: + """Get the helper instance.""" + return self.converter_manager.helper diff --git a/music_assistant/providers/nicovideo/converters/helper.py b/music_assistant/providers/nicovideo/converters/helper.py new file mode 100644 index 0000000000..32bf9ee8e3 --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/helper.py @@ -0,0 +1,64 @@ +""" +Helper utilities for nicovideo converters. + +Provides common utility functions and lightweight mapping creation for converters. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from music_assistant_models.media_items import ProviderMapping + +from music_assistant.providers.nicovideo.converters.base import NicovideoConverterBase + +if TYPE_CHECKING: + from music_assistant_models.media_items import AudioFormat + +# Type alias for nicovideo URL path types +type NicovideoUrlPath = Literal["watch", "mylist", "series", "user", "channel"] + + +class NicovideoConverterHelper(NicovideoConverterBase): + """Helper for creating various mapping objects and utility functions.""" + + def calculate_popularity( + self, + mylist_count: int | None = None, + like_count: int | None = None, + ) -> int: + """Calculate popularity score using standard formula. + + Returns: + Popularity score (0-100). + """ + # Primary calculation: mylist*3 + like*1 (normalized to 0-100 scale) + if mylist_count is not None and like_count is not None: + return min(100, max(0, int((mylist_count * 3 + like_count) / 10))) + + return 0 + + # ProviderMapping creation methods + def create_provider_mapping( + self, + item_id: str, + url_path: NicovideoUrlPath, + *, + available: bool = True, + audio_format: AudioFormat | None = None, + ) -> set[ProviderMapping]: + """Create provider mapping for media items.""" + # Create mapping with required fields + mapping = ProviderMapping( + item_id=item_id, + provider_domain=self.provider.domain, + provider_instance=self.provider.instance_id, + url=f"https://www.nicovideo.jp/{url_path}/{item_id}", + available=available, + ) + + # Set audio_format if provided + if audio_format is not None: + mapping.audio_format = audio_format + + return {mapping} diff --git a/music_assistant/providers/nicovideo/converters/manager.py b/music_assistant/providers/nicovideo/converters/manager.py new file mode 100644 index 0000000000..298a6d7900 --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/manager.py @@ -0,0 +1,43 @@ +""" +Manager class for nicovideo converters. + +Converters Layer: Data transformation +- Converts niconico.py objects to Music Assistant models +- Handles metadata mapping and normalization +- Manages item relationships and cross-references +- Provides consistent data format for provider mixins +""" + +from __future__ import annotations + +from logging import Logger +from typing import TYPE_CHECKING + +from music_assistant.providers.nicovideo.converters.album import NicovideoAlbumConverter +from music_assistant.providers.nicovideo.converters.artist import NicovideoArtistConverter +from music_assistant.providers.nicovideo.converters.helper import NicovideoConverterHelper +from music_assistant.providers.nicovideo.converters.playlist import ( + NicovideoPlaylistConverter, +) +from music_assistant.providers.nicovideo.converters.stream import NicovideoStreamConverter +from music_assistant.providers.nicovideo.converters.track import NicovideoTrackConverter + +if TYPE_CHECKING: + from music_assistant.models.music_provider import MusicProvider + + +class NicovideoConverterManager: + """Central manager for all nicovideo converters to Music Assistant media items.""" + + def __init__(self, provider: MusicProvider, logger: Logger) -> None: + """Initialize with provider and create specialized converters.""" + self.provider = provider + self.logger = logger + self.helper = NicovideoConverterHelper(self) + + # Initialize specialized converters + self.track = NicovideoTrackConverter(self) + self.album = NicovideoAlbumConverter(self) + self.playlist = NicovideoPlaylistConverter(self) + self.artist = NicovideoArtistConverter(self) + self.stream = NicovideoStreamConverter(self) diff --git a/music_assistant/providers/nicovideo/converters/playlist.py b/music_assistant/providers/nicovideo/converters/playlist.py new file mode 100644 index 0000000000..99dedd7d3c --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/playlist.py @@ -0,0 +1,77 @@ +"""Playlist converter for nicovideo objects.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ImageType, LinkType +from music_assistant_models.media_items import ( + MediaItemImage, + MediaItemLink, + MediaItemMetadata, + Playlist, +) +from music_assistant_models.unique_list import UniqueList +from niconico.objects.video.search import EssentialMylist + +from music_assistant.providers.nicovideo.converters.base import NicovideoConverterBase +from music_assistant.providers.nicovideo.helpers import PlaylistWithTracks + +if TYPE_CHECKING: + from niconico.objects.nvapi import FollowingMylistItem + from niconico.objects.user import UserMylistItem + from niconico.objects.video import Mylist + + +class NicovideoPlaylistConverter(NicovideoConverterBase): + """Handles playlist conversion for nicovideo.""" + + def convert_by_mylist(self, mylist: UserMylistItem | Mylist | EssentialMylist) -> Playlist: + """Convert a nicovideo UserMylistItem into a Playlist.""" + playlist = Playlist( + item_id=str(mylist.id_), + provider=self.provider.lookup_key, + name=(mylist.title if isinstance(mylist, EssentialMylist) else mylist.name), + owner=mylist.owner.id_ or "", + is_editable=True, # Own mylists are editable by default + metadata=MediaItemMetadata( + description=mylist.description, + links={ + MediaItemLink( + type=LinkType.WEBSITE, + url=f"https://www.nicovideo.jp/mylist/{mylist.id_}", + ) + }, + ), + provider_mappings=self.helper.create_provider_mapping(str(mylist.id_), "mylist"), + ) + + if mylist.owner.icon_url: + if not playlist.metadata.images: + playlist.metadata.images = UniqueList() + playlist.metadata.images.append( + MediaItemImage( + type=ImageType.THUMB, + path=mylist.owner.icon_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ) + return playlist + + def convert_following_by_mylist(self, mylist: FollowingMylistItem) -> Playlist: + """Convert a nicovideo UserMylistItem from following users into a read-only Playlist.""" + playlist = self.convert_by_mylist(mylist.detail) + # Mark following mylists as non-editable + playlist.is_editable = False + return playlist + + def convert_with_tracks_by_mylist(self, mylist: Mylist) -> PlaylistWithTracks: + """Convert a nicovideo UserMylistItem into a PlaylistWithTracks.""" + playlist = self.convert_by_mylist(mylist) + tracks = [] + for item in mylist.items: + track = self.converter_manager.track.convert_by_essential_video(item.video) + if track: + tracks.append(track) + return PlaylistWithTracks(playlist, tracks) diff --git a/music_assistant/providers/nicovideo/converters/stream.py b/music_assistant/providers/nicovideo/converters/stream.py new file mode 100644 index 0000000000..3b1ed9a2de --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/stream.py @@ -0,0 +1,110 @@ +"""Stream converter for nicovideo objects.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from music_assistant_models.enums import MediaType, StreamType +from music_assistant_models.errors import UnplayableMediaError +from music_assistant_models.streamdetails import StreamDetails, StreamMetadata +from niconico.objects.video.watch import ( # noqa: TC002 - Using by StreamConversionData(BaseModel Serialization) + WatchData, + WatchMediaDomandAudio, +) +from pydantic import BaseModel + +from music_assistant.providers.nicovideo.converters.base import NicovideoConverterBase +from music_assistant.providers.nicovideo.helpers import create_audio_format +from music_assistant.providers.nicovideo.helpers.hls_models import ParsedHLSPlaylist + + +@dataclass +class NicovideoStreamData: + """Type-safe container for nicovideo HLS streaming data. + + This dataclass is stored in StreamDetails.data to pass + HLS-specific information to get_audio_stream(). + + Attributes: + domand_bid: Authentication cookie value + parsed_hls_playlist: Pre-parsed HLS playlist data (fetched once during conversion) + """ + + domand_bid: str + parsed_hls_playlist: ParsedHLSPlaylist + + +class StreamConversionData(BaseModel): + """Data needed for StreamDetails conversion.""" + + watch_data: WatchData + selected_audio: WatchMediaDomandAudio + hls_url: str + domand_bid: str + hls_playlist_text: str + + +class NicovideoStreamConverter(NicovideoConverterBase): + """Handles StreamDetails conversion for nicovideo. + + This converter transforms nicovideo video data into MusicAssistant StreamDetails + using StreamType.CUSTOM for optimized HLS streaming with fast seeking support. + """ + + def convert_from_conversion_data(self, conversion_data: StreamConversionData) -> StreamDetails: + """Convert StreamConversionData into StreamDetails. + + Args: + conversion_data: Data containing video info, audio selection, and HLS details + + Returns: + StreamDetails configured for custom HLS streaming with seek optimization + + Raises: + UnplayableMediaError: If track data cannot be converted + """ + watch_data = conversion_data.watch_data + selected_audio = conversion_data.selected_audio + video_id = watch_data.video.id_ + + # Get track information for stream metadata + track = self.converter_manager.track.convert_by_watch_data(watch_data) + if not track: + raise UnplayableMediaError(f"Cannot convert track data for video {video_id}") + + # Get album and image information + album = track.album + # Do not use album image intentionally + image = track.image if track else None + + parsed_playlist = ParsedHLSPlaylist.from_text(conversion_data.hls_playlist_text) + + return StreamDetails( + provider=self.provider.instance_id, + item_id=video_id, + audio_format=create_audio_format( + sample_rate=selected_audio.sampling_rate, + bit_rate=selected_audio.bit_rate, + ), + media_type=MediaType.TRACK, + # CUSTOM stream type enables optimized seeking for nicovideo's fMP4-based HLS: + # 1. Generate dynamic playlist starting near target position (coarse seek) + # 2. Use input-side -ss within segment boundary (fine-tune) + # Note: Input-side -ss can't cross segment boundaries; output-side could but + # would require full decode of all prior segments. + stream_type=StreamType.CUSTOM, + duration=watch_data.video.duration, + stream_metadata=StreamMetadata( + title=track.name, + artist=track.artist_str, + album=album.name if album else None, + image_url=image.path if image else None, + ), + loudness=selected_audio.integrated_loudness, + data=NicovideoStreamData( + domand_bid=conversion_data.domand_bid, + parsed_hls_playlist=parsed_playlist, + ), + allow_seek=True, + can_seek=True, + ) diff --git a/music_assistant/providers/nicovideo/converters/track.py b/music_assistant/providers/nicovideo/converters/track.py new file mode 100644 index 0000000000..4dc4b1e644 --- /dev/null +++ b/music_assistant/providers/nicovideo/converters/track.py @@ -0,0 +1,430 @@ +"""Track converter for nicovideo objects.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ImageType, LinkType +from music_assistant_models.media_items import ( + Artist, + AudioFormat, + ItemMapping, + MediaItemImage, + MediaItemLink, + MediaItemMetadata, + Track, +) +from music_assistant_models.unique_list import UniqueList +from niconico.objects.video import EssentialVideo, Owner, VideoThumbnail + +from music_assistant.providers.nicovideo.converters.base import NicovideoConverterBase +from music_assistant.providers.nicovideo.helpers import create_audio_format + +if TYPE_CHECKING: + from niconico.objects.nvapi import Activity + from niconico.objects.video.watch import WatchData, WatchVideo, WatchVideoThumbnail + + +class NicovideoTrackConverter(NicovideoConverterBase): + """Handles track conversion for nicovideo.""" + + def convert_by_activity(self, activity: Activity) -> Track | None: + """Convert an Activity object from feed into a Track. + + This is a lightweight conversion optimized for feed display, + using only the information available in the activity data. + Missing information like view counts and detailed metadata + will be absent, but this is acceptable for feed listings. + """ + content = activity.content + + # Only process video content + if content.type_ != "video" or not content.video: + return None + + # Create audio format with minimal info + audio_format = create_audio_format() + + # Build artists from actor information using ItemMapping + artists_list: UniqueList[Artist | ItemMapping] = UniqueList() + if activity.actor.id_ and activity.actor.name: + artist_mapping = ItemMapping( + item_id=activity.actor.id_, + provider=self.provider.domain, + name=activity.actor.name, + ) + artists_list.append(artist_mapping) + + # Create track with available information + return Track( + item_id=content.id_, + provider=self.provider.lookup_key, + name=content.title, + duration=content.video.duration, + artists=artists_list, + # Assume playable if duration > 0 (we don't have payment info here) + is_playable=content.video.duration > 0, + metadata=self._create_track_metadata( + video_id=content.id_, + release_date_str=content.started_at, + thumbnail_url=activity.thumbnail_url, + ), + provider_mappings=self.helper.create_provider_mapping( + item_id=content.id_, + url_path="watch", + # We don't have availability info, so default to True if playable + available=content.video.duration > 0, + audio_format=audio_format, + ), + ) + + def convert_by_essential_video(self, video: EssentialVideo) -> Track | None: + """Convert an EssentialVideo object into a Track.""" + # Skip muted videos + if video.is_muted: + return None + + # Calculate popularity using standard formula + popularity = self.helper.calculate_popularity( + mylist_count=video.count.mylist, + like_count=video.count.like, + ) + + # Since EssentialVideo doesn't have detailed audio format info, we use defaults + audio_format = create_audio_format() + + # Build artists using artist converter (prefer full Artist over ItemMapping) + artists_list: UniqueList[Artist | ItemMapping] = UniqueList() + if video.owner.id_ is not None: + artist_obj = self.converter_manager.artist.convert_by_owner_or_user(video.owner) + artists_list.append(artist_obj) + + # Create base track with enhanced metadata + return Track( + item_id=video.id_, + provider=self.provider.lookup_key, + name=video.title, + duration=video.duration, + artists=artists_list, + # Videos that cannot be played will have a duration of 0. + is_playable=video.duration > 0 and not video.is_payment_required, + metadata=self._create_track_metadata( + video_id=video.id_, + description=video.short_description, + explicit=video.require_sensitive_masking, + release_date_str=video.registered_at, + popularity=popularity, + thumbnail=video.thumbnail, + ), + provider_mappings=self.helper.create_provider_mapping( + item_id=video.id_, + url_path="watch", + available=self.is_video_available(video), + audio_format=audio_format, + ), + ) + + def convert_by_watch_data(self, watch_data: WatchData) -> Track | None: + """Convert a WatchData object into a Track.""" + video = watch_data.video + + # Skip deleted, private, or muted videos + if video.is_deleted or video.is_private: + return None + + # Calculate popularity using standard formula + popularity = self.helper.calculate_popularity( + mylist_count=video.count.mylist, + like_count=video.count.like, + ) + + # Create owner object for artist conversion based on channel vs user video + if watch_data.channel: + # Channel video case + owner = Owner( + ownerType="channel", + type="channel", + visibility="visible", + id=watch_data.channel.id_, + name=watch_data.channel.name, + iconUrl=watch_data.channel.thumbnail.url if watch_data.channel.thumbnail else None, + ) + else: + # User video case + owner = Owner( + ownerType="user", + type="user", + visibility="visible", + id=str(watch_data.owner.id_) if watch_data.owner else None, + name=watch_data.owner.nickname if watch_data.owner else None, + iconUrl=watch_data.owner.icon_url if watch_data.owner else None, + ) + + # Create audio format from watch data + audio_format = self._create_audio_format_from_watch_data(watch_data) + + # Build artists using artist converter (avoid adding if owner id is missing) + artists_list: UniqueList[Artist | ItemMapping] = UniqueList() + if owner.id_ is not None: + artist_obj = self.converter_manager.artist.convert_by_owner_or_user(owner) + artists_list.append(artist_obj) + + # Create base track with enhanced metadata + track = Track( + item_id=video.id_, + provider=self.provider.lookup_key, + name=video.title, + duration=video.duration, + artists=artists_list, + # Videos that cannot be played will have a duration of 0. + is_playable=video.duration > 0 and not video.is_authentication_required, + metadata=self._create_track_metadata_from_watch_video( + video=video, + watch_data=watch_data, + popularity=popularity, + ), + provider_mappings=self.helper.create_provider_mapping( + item_id=video.id_, + url_path="watch", + available=self.is_video_available(video), + audio_format=audio_format, + ), + ) + + # Add album information if series data is available (prefer full Album over ItemMapping) + if watch_data.series is not None: + track.album = self.converter_manager.album.convert_by_series( + watch_data.series, + artists_list=artists_list, + ) + + return track + + def _create_audio_format_from_watch_data(self, watch_data: WatchData) -> AudioFormat | None: + """Create AudioFormat from WatchData audio information. + + Args: + watch_data: WatchData object containing media information. + + Returns: + AudioFormat object if audio information is available, None otherwise. + """ + if ( + not watch_data.media + or not watch_data.media.domand + or not watch_data.media.domand.audios + ): + return None + + # Use the first available audio stream (typically the highest quality) + audio = watch_data.media.domand.audios[0] + + if not audio.is_available: + return None + + return create_audio_format( + sample_rate=audio.sampling_rate, + bit_rate=audio.bit_rate, + ) + + def _create_track_metadata_from_watch_video( + self, + video: WatchVideo, + watch_data: WatchData, + *, + popularity: int | None = None, + ) -> MediaItemMetadata: + """Create track metadata from WatchVideo object.""" + metadata = MediaItemMetadata() + + if video.description: + metadata.description = video.description + + if video.registered_at: + try: + # Handle both direct ISO format and Z-suffixed format + if video.registered_at.endswith("Z"): + clean_date_str = video.registered_at.replace("Z", "+00:00") + metadata.release_date = datetime.fromisoformat(clean_date_str) + else: + metadata.release_date = datetime.fromisoformat(video.registered_at) + except (ValueError, AttributeError) as err: + # Log debug message for date parsing failures to help with troubleshooting + self.logger.debug( + "Failed to convert release date '%s': %s", video.registered_at, err + ) + + if popularity is not None: + metadata.popularity = popularity + + # Add tag information as genres + if watch_data.tag and watch_data.tag.items: + # Extract tag names from tag items and create genres set + tag_names: list[str] = [] + for tag_item in watch_data.tag.items: + tag_names.append(tag_item.name) + + if tag_names: + metadata.genres = set(tag_names) + + # Add thumbnail images + if video.thumbnail: + metadata.images = self._convert_watch_video_thumbnails(video.thumbnail) + + # Add video link + metadata.links = { + MediaItemLink( + type=LinkType.WEBSITE, + url=f"https://www.nicovideo.jp/watch/{video.id_}", + ) + } + + return metadata + + def _convert_watch_video_thumbnails( + self, thumbnail: WatchVideoThumbnail + ) -> UniqueList[MediaItemImage]: + """Convert WatchVideo thumbnails into multiple image sizes.""" + images: UniqueList[MediaItemImage] = UniqueList() + + def _add_thumbnail_image(url: str) -> None: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ) + + # Add main thumbnail URLs + if thumbnail.url: + _add_thumbnail_image(thumbnail.url) + if thumbnail.middle_url: + _add_thumbnail_image(thumbnail.middle_url) + if thumbnail.large_url: + _add_thumbnail_image(thumbnail.large_url) + + return images + + def _create_track_metadata( + self, + video_id: str, + *, + description: str | None = None, + explicit: bool | None = None, + release_date_str: str | None = None, + popularity: int | None = None, + thumbnail: VideoThumbnail | None = None, + thumbnail_url: str | None = None, + ) -> MediaItemMetadata: + """Create track metadata with common fields.""" + metadata = MediaItemMetadata() + + if description: + metadata.description = description + + if explicit is not None: + metadata.explicit = explicit + + if release_date_str: + try: + # Handle both direct ISO format and Z-suffixed format + if release_date_str.endswith("Z"): + clean_date_str = release_date_str.replace("Z", "+00:00") + metadata.release_date = datetime.fromisoformat(clean_date_str) + else: + metadata.release_date = datetime.fromisoformat(release_date_str) + except (ValueError, AttributeError) as err: + # Log debug message for date parsing failures to help with troubleshooting + self.logger.debug("Failed to convert release date '%s': %s", release_date_str, err) + + if popularity is not None: + metadata.popularity = popularity + + # Add thumbnail images with enhanced support + if thumbnail: + # Use enhanced thumbnail parsing for multiple sizes + metadata.images = self._convert_video_thumbnails(thumbnail) + elif thumbnail_url: + # Fallback to single thumbnail URL + metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=thumbnail_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ] + ) + + # Add video link + metadata.links = { + MediaItemLink( + type=LinkType.WEBSITE, + url=f"https://www.nicovideo.jp/watch/{video_id}", + ) + } + + return metadata + + def _convert_video_thumbnails(self, thumbnail: VideoThumbnail) -> UniqueList[MediaItemImage]: + """Convert video thumbnails into multiple image sizes.""" + images: UniqueList[MediaItemImage] = UniqueList() + + # nhd_url is the largest size, use it as primary + if thumbnail.nhd_url: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=thumbnail.nhd_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ) + + # large_url as secondary (if different from nhd_url) + if thumbnail.large_url and thumbnail.large_url != thumbnail.nhd_url: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=thumbnail.large_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ) + + # middle_url and listing_url are same size, skip them if nhd_url exists + # Only add if nhd_url is not available + if not thumbnail.nhd_url and thumbnail.middle_url: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=thumbnail.middle_url, + provider=self.provider.lookup_key, + remotely_accessible=True, + ) + ) + + return images + + def is_video_available(self, video: EssentialVideo | WatchVideo) -> bool: + """Check if a video is available for playback. + + Args: + video: Either EssentialVideo or WatchVideo object. + + Returns: + True if the video is available for playback, False otherwise. + """ + # Common check: duration must be greater than 0 + if video.duration <= 0: + return False + + # Type-specific availability checks + if isinstance(video, EssentialVideo): + return not video.is_payment_required and not video.is_muted + else: # WatchVideo + return not video.is_deleted diff --git a/music_assistant/providers/nicovideo/helpers/__init__.py b/music_assistant/providers/nicovideo/helpers/__init__.py new file mode 100644 index 0000000000..6809b3bf84 --- /dev/null +++ b/music_assistant/providers/nicovideo/helpers/__init__.py @@ -0,0 +1,27 @@ +"""Helper functions for nicovideo provider.""" + +from music_assistant.providers.nicovideo.helpers.hls_models import ( + HLSSegment, + ParsedHLSPlaylist, +) +from music_assistant.providers.nicovideo.helpers.hls_seek_optimizer import ( + HLSSeekOptimizer, + SeekOptimizedStreamContext, +) +from music_assistant.providers.nicovideo.helpers.utils import ( + AlbumWithTracks, + PlaylistWithTracks, + create_audio_format, + log_verbose, +) + +__all__ = [ + "AlbumWithTracks", + "HLSSeekOptimizer", + "HLSSegment", + "ParsedHLSPlaylist", + "PlaylistWithTracks", + "SeekOptimizedStreamContext", + "create_audio_format", + "log_verbose", +] diff --git a/music_assistant/providers/nicovideo/helpers/hls_models.py b/music_assistant/providers/nicovideo/helpers/hls_models.py new file mode 100644 index 0000000000..4c10d6c8ee --- /dev/null +++ b/music_assistant/providers/nicovideo/helpers/hls_models.py @@ -0,0 +1,95 @@ +"""HLS data models and parsing for nicovideo provider.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass +class HLSSegment: + """Single HLS segment entry. + + Attributes: + duration_line: #EXTINF line with duration (e.g., "#EXTINF:5.967528,") + segment_url: URL to the segment file + """ + + duration_line: str + segment_url: str + + +@dataclass +class ParsedHLSPlaylist: + """Parsed HLS playlist data. + + Attributes: + init_segment_url: URL to the initialization segment (#EXT-X-MAP) + encryption_key_line: Encryption key line (#EXT-X-KEY) if present + segments: List of HLS segments + header_lines: Playlist header lines (#EXTM3U, #EXT-X-VERSION, etc.) + """ + + init_segment_url: str + encryption_key_line: str + segments: list[HLSSegment] + header_lines: list[str] + + @classmethod + def from_text(cls, hls_playlist_text: str) -> ParsedHLSPlaylist: + """Parse HLS playlist text into structured data. + + Args: + hls_playlist_text: HLS playlist text + + Returns: + ParsedHLSPlaylist object with extracted data + """ + lines = [line.strip() for line in hls_playlist_text.split("\n") if line.strip()] + + # Extract header lines (#EXTM3U, #EXT-X-VERSION, etc.) + header_lines = [] + for line in lines: + if line.startswith("#EXT-X-TARGETDURATION"): + break + if line.startswith("#EXT"): + header_lines.append(line) + + # Extract init segment URL from #EXT-X-MAP + init_segment_url = "" + for line in lines: + if line.startswith("#EXT-X-MAP:"): + match = re.search(r'URI="([^"]+)"', line) + if match: + init_segment_url = match.group(1) + break + + # Extract encryption key line + encryption_key_line = "" + for line in lines: + if line.startswith("#EXT-X-KEY:"): + encryption_key_line = line + break + + # Extract segments (duration + URL pairs) + segments: list[HLSSegment] = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("#EXTINF:"): + duration_line = line + # Next line should be segment URL + if i + 1 < len(lines): + segment_url = lines[i + 1] + if not segment_url.startswith("#"): + segments.append(HLSSegment(duration_line, segment_url)) + i += 2 + continue + i += 1 + + return cls( + init_segment_url=init_segment_url, + encryption_key_line=encryption_key_line, + segments=segments, + header_lines=header_lines, + ) diff --git a/music_assistant/providers/nicovideo/helpers/hls_seek_optimizer.py b/music_assistant/providers/nicovideo/helpers/hls_seek_optimizer.py new file mode 100644 index 0000000000..f84b9e66c2 --- /dev/null +++ b/music_assistant/providers/nicovideo/helpers/hls_seek_optimizer.py @@ -0,0 +1,178 @@ +"""HLS seek optimizer for nicovideo provider.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from music_assistant.providers.nicovideo.constants import ( + DOMAND_BID_COOKIE_NAME, + NICOVIDEO_USER_AGENT, +) +from music_assistant.providers.nicovideo.helpers.utils import log_verbose + +if TYPE_CHECKING: + from music_assistant.providers.nicovideo.converters.stream import NicovideoStreamData + from music_assistant.providers.nicovideo.helpers.hls_models import ParsedHLSPlaylist + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class SeekOptimizedStreamContext: + """Context for seek-optimized HLS streaming. + + Contains all information needed to set up streaming with fast seeking: + - Dynamic playlist content to serve + - FFmpeg extra input arguments (headers, seeking) + """ + + dynamic_playlist_text: str + extra_input_args: list[str] + + +class HLSSeekOptimizer: + """Optimizes HLS streaming with fast seeking support. + + Generates dynamic HLS playlists and FFmpeg arguments for efficient + seeking by calculating optimal segment start positions. + + This eliminates the need to decode all segments before the target position, + enabling instant seeking in long nicovideo streams. + """ + + def __init__( + self, + hls_data: NicovideoStreamData, + ) -> None: + """Initialize seek optimizer with HLS data. + + Args: + hls_data: HLS streaming data containing parsed playlist and authentication info + """ + self.parsed_playlist: ParsedHLSPlaylist = hls_data.parsed_hls_playlist + self.domand_bid = hls_data.domand_bid + + def _calculate_start_segment(self, seek_position: int) -> tuple[int, float]: + """Calculate which segment to start from based on seek position. + + Args: + seek_position: Desired seek position in seconds + + Returns: + Tuple of (segment_index, offset_within_segment) + - segment_index: Index of the segment to start from + - offset_within_segment: Seconds to skip within that segment + """ + if seek_position <= 0: + return (0, 0.0) + + accumulated_time = 0.0 + for idx, segment in enumerate(self.parsed_playlist.segments): + # Extract duration from #EXTINF:5.967528, + match = re.search(r"#EXTINF:([0-9.]+)", segment.duration_line) + if match: + segment_duration = float(match.group(1)) + if accumulated_time + segment_duration > seek_position: + # Found the segment containing seek_position + offset = seek_position - accumulated_time + return (idx, offset) + accumulated_time += segment_duration + + # If seek position is beyond total duration, start from last segment + return (max(0, len(self.parsed_playlist.segments) - 1), 0.0) + + def _generate_dynamic_playlist(self, start_segment_idx: int) -> str: + """Generate dynamic HLS playlist with segments from start_segment_idx onward. + + Args: + start_segment_idx: Index to start from + + Returns: + Dynamic HLS playlist text + """ + lines = [] + + # Add header lines + lines.extend(self.parsed_playlist.header_lines) + + # Calculate target duration from segments (rounded up) + max_duration = 6 # Default fallback + for segment in self.parsed_playlist.segments: + match = re.search(r"#EXTINF:([0-9.]+)", segment.duration_line) + if match: + duration = float(match.group(1)) + max_duration = max(max_duration, int(duration) + 1) + + # Add required HLS tags + lines.extend( + [ + f"#EXT-X-TARGETDURATION:{max_duration}", + "#EXT-X-MEDIA-SEQUENCE:1", + "#EXT-X-PLAYLIST-TYPE:VOD", + ] + ) + + # Add init segment + if self.parsed_playlist.init_segment_url: + lines.append(f'#EXT-X-MAP:URI="{self.parsed_playlist.init_segment_url}"') + + # Add encryption key if present + if self.parsed_playlist.encryption_key_line: + lines.append(self.parsed_playlist.encryption_key_line) + + # Add segments from start_segment_idx onward + for segment in self.parsed_playlist.segments[start_segment_idx:]: + lines.append(segment.duration_line) + lines.append(segment.segment_url) + + # Add end tag + lines.append("#EXT-X-ENDLIST") + + return "\n".join(lines) + + def create_stream_context(self, seek_position: int) -> SeekOptimizedStreamContext: + """Create seek-optimized streaming context. + + This method combines segment calculation, playlist generation, + and FFmpeg arguments preparation for fast seeking. + + Args: + seek_position: Position to seek to in seconds + + Returns: + SeekOptimizedStreamContext with all streaming setup information + """ + # Stage 1: Calculate which segment contains the seek position (coarse seek) + # This avoids processing unnecessary segments before the target position + start_segment_idx, offset_within_segment = self._calculate_start_segment(seek_position) + if seek_position > 0: + log_verbose( + LOGGER, + "HLS seek: position=%ds → segment %d/%d (offset %.2fs)", + seek_position, + start_segment_idx, + len(self.parsed_playlist.segments), + offset_within_segment, + ) + + # Generate HLS playlist starting from the calculated segment + dynamic_playlist_text = self._generate_dynamic_playlist(start_segment_idx) + + # Build FFmpeg extra input arguments + headers = ( + f"User-Agent: {NICOVIDEO_USER_AGENT}\r\n" + f"Cookie: {DOMAND_BID_COOKIE_NAME}={self.domand_bid}\r\n" + ) + extra_input_args = ["-headers", headers] + + # Stage 2: Apply input-side -ss for fine-tuning within the segment + if offset_within_segment > 0: + extra_input_args.extend(["-ss", str(offset_within_segment)]) + + return SeekOptimizedStreamContext( + dynamic_playlist_text=dynamic_playlist_text, + extra_input_args=extra_input_args, + ) diff --git a/music_assistant/providers/nicovideo/helpers/utils.py b/music_assistant/providers/nicovideo/helpers/utils.py new file mode 100644 index 0000000000..453e823bb5 --- /dev/null +++ b/music_assistant/providers/nicovideo/helpers/utils.py @@ -0,0 +1,75 @@ +"""Utility functions for handling cookies and converting them into Netscape format.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from mashumaro import DataClassDictMixin + +# Playlist, Album, and Track cannot be placed under TYPE_CHECKING +# because they are used at runtime by DataClassDictMixin +from music_assistant_models.media_items import ( + Album, + AudioFormat, + Playlist, + Track, +) + +from music_assistant.constants import VERBOSE_LOG_LEVEL +from music_assistant.providers.nicovideo.constants import ( + NICOVIDEO_AUDIO_BIT_DEPTH, + NICOVIDEO_AUDIO_CHANNELS, + NICOVIDEO_CODEC_TYPE, + NICOVIDEO_CONTENT_TYPE, +) + +if TYPE_CHECKING: + import logging + + +@dataclass +class PlaylistWithTracks(DataClassDictMixin): + """Helper class to hold playlist and its tracks.""" + + playlist: Playlist + tracks: list[Track] + + +@dataclass +class AlbumWithTracks(DataClassDictMixin): + """Helper class to hold album and its tracks.""" + + album: Album + tracks: list[Track] + + +def log_verbose(logger: logging.Logger, message: str, *args: object) -> None: + """Log a message at VERBOSE level with performance optimization. + + Args: + logger: Logger instance + message: Log message format string + *args: Arguments for the message format string + """ + if logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logger.log(VERBOSE_LOG_LEVEL, message, *args) + + +def create_audio_format( + *, bit_rate: int | None = None, sample_rate: int | None = None +) -> AudioFormat: + """Create AudioFormat from stream format data.""" + audio_format = AudioFormat( + content_type=NICOVIDEO_CONTENT_TYPE, + codec_type=NICOVIDEO_CODEC_TYPE, + channels=NICOVIDEO_AUDIO_CHANNELS, + bit_depth=NICOVIDEO_AUDIO_BIT_DEPTH, + ) + + if bit_rate is not None: + audio_format.bit_rate = bit_rate + if sample_rate is not None: + audio_format.sample_rate = sample_rate + + return audio_format diff --git a/music_assistant/providers/nicovideo/icon.svg b/music_assistant/providers/nicovideo/icon.svg new file mode 100644 index 0000000000..5a30de5298 --- /dev/null +++ b/music_assistant/providers/nicovideo/icon.svg @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/music_assistant/providers/nicovideo/icon_monochrome.svg b/music_assistant/providers/nicovideo/icon_monochrome.svg new file mode 100644 index 0000000000..849a1f364b --- /dev/null +++ b/music_assistant/providers/nicovideo/icon_monochrome.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/music_assistant/providers/nicovideo/manifest.json b/music_assistant/providers/nicovideo/manifest.json new file mode 100644 index 0000000000..b78ccbba1e --- /dev/null +++ b/music_assistant/providers/nicovideo/manifest.json @@ -0,0 +1,11 @@ +{ + "type": "music", + "domain": "nicovideo", + "name": "niconico video", + "description": "Support for niconico video(nicovideo) in Music Assistant", + "codeowners": ["@Shi-553"], + "requirements": [ + "niconico.py @ git+https://github.com/Shi-553/niconico.py@b2d781de11df4bf42d0d42037ac7f5abb6822fda" + ], + "multi_instance": true +} diff --git a/music_assistant/providers/nicovideo/provider.py b/music_assistant/providers/nicovideo/provider.py new file mode 100644 index 0000000000..45e28dfc4f --- /dev/null +++ b/music_assistant/providers/nicovideo/provider.py @@ -0,0 +1,63 @@ +""" +NicovideoMusicProvider: Coordinator that combines all mixins. + +This is the main provider class that acts as a coordinator and aggregator: +- Combines all domain-specific mixins (Track, Playlist, Album, Artist, etc.) +- Delegates cross-mixin operations through _for_mixin patterns +- Handles provider-wide operations that span multiple domains + +Architecture Overview: +├── services/: API integration and data transformation coordination +│ └── Coordinates API calls through niconico.py, manages rate limiting, and delegates conversion +├── converters/: Data transformation layer +│ └── Converts niconico objects to Music Assistant models +└── provider_mixins/: Business logic layer + └── Implements Music Assistant provider interface methods +""" + +from __future__ import annotations + +from typing import override + +from music_assistant.providers.nicovideo.provider_mixins import ( + NicovideoMusicProviderAlbumMixin, + NicovideoMusicProviderArtistMixin, + NicovideoMusicProviderCoreMixin, + NicovideoMusicProviderExplorerMixin, + NicovideoMusicProviderPlaylistMixin, + NicovideoMusicProviderTrackMixin, +) + +# Tuple of mixin classes in inheritance order. +# Used for provider-wide operations that span all mixins (e.g. init, unload) +NICOVIDEO_MIXINS = ( + NicovideoMusicProviderCoreMixin, + NicovideoMusicProviderTrackMixin, + NicovideoMusicProviderPlaylistMixin, + NicovideoMusicProviderArtistMixin, + NicovideoMusicProviderAlbumMixin, + NicovideoMusicProviderExplorerMixin, +) + + +class NicovideoMusicProvider( + NicovideoMusicProviderCoreMixin, + NicovideoMusicProviderTrackMixin, + NicovideoMusicProviderPlaylistMixin, + NicovideoMusicProviderArtistMixin, + NicovideoMusicProviderAlbumMixin, + NicovideoMusicProviderExplorerMixin, +): + """Coordinator combining all nicovideo provider mixins.""" + + @override + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + for mixin_class in NICOVIDEO_MIXINS: + await mixin_class.handle_async_init_for_mixin(self) + + @override + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + for mixin_class in NICOVIDEO_MIXINS[::-1]: + await mixin_class.unload_for_mixin(self, is_removed) diff --git a/music_assistant/providers/nicovideo/provider_mixins/__init__.py b/music_assistant/providers/nicovideo/provider_mixins/__init__.py new file mode 100644 index 0000000000..167bc0bd47 --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/__init__.py @@ -0,0 +1,25 @@ +""" +nicovideo provider mixins package. + +Provider Mixins Layer: Business logic +Implements Music Assistant provider interface methods. +Each mixin handles specific media types and provider capabilities. +""" + +from __future__ import annotations + +from .album import NicovideoMusicProviderAlbumMixin +from .artist import NicovideoMusicProviderArtistMixin +from .core import NicovideoMusicProviderCoreMixin +from .explorer import NicovideoMusicProviderExplorerMixin +from .playlist import NicovideoMusicProviderPlaylistMixin +from .track import NicovideoMusicProviderTrackMixin + +__all__ = [ + "NicovideoMusicProviderAlbumMixin", + "NicovideoMusicProviderArtistMixin", + "NicovideoMusicProviderCoreMixin", + "NicovideoMusicProviderExplorerMixin", + "NicovideoMusicProviderPlaylistMixin", + "NicovideoMusicProviderTrackMixin", +] diff --git a/music_assistant/providers/nicovideo/provider_mixins/album.py b/music_assistant/providers/nicovideo/provider_mixins/album.py new file mode 100644 index 0000000000..5ac9054e67 --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/album.py @@ -0,0 +1,49 @@ +""" +MixIn for NicovideoMusicProvider: album-related methods. + +In this section, we treat niconico's "series" as an album. +""" + +from __future__ import annotations + +from typing import override + +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import Album, Track # noqa: TC002 - used in @use_cache + +from music_assistant.controllers.cache import use_cache +from music_assistant.providers.nicovideo.provider_mixins.base import ( + NicovideoMusicProviderMixinBase, +) + + +class NicovideoMusicProviderAlbumMixin(NicovideoMusicProviderMixinBase): + """Album-related methods for NicovideoMusicProvider.""" + + @override + @use_cache(3600 * 24 * 7) # Cache for 7 days + async def get_album(self, prov_album_id: str) -> Album: + """Get full album details by id (series as album).""" + album_with_tracks = await self.service_manager.series.get_series_or_own_series( + prov_album_id + ) + if not album_with_tracks: + raise MediaNotFoundError(f"Album with id {prov_album_id} not found on nicovideo.") + + return album_with_tracks.album + + @override + @use_cache(3600 * 24 * 7) # Cache for 7 days + async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + """Get album tracks for given album id (series tracks).""" + album_with_tracks = await self.service_manager.series.get_series_or_own_series( + prov_album_id + ) + if not album_with_tracks: + return [] + + # Set album information on tracks (cached by @use_cache) + for track in album_with_tracks.tracks: + track.album = album_with_tracks.album + + return album_with_tracks.tracks diff --git a/music_assistant/providers/nicovideo/provider_mixins/artist.py b/music_assistant/providers/nicovideo/provider_mixins/artist.py new file mode 100644 index 0000000000..fdad6c5b8a --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/artist.py @@ -0,0 +1,57 @@ +"""MixIn for NicovideoMusicProvider: artist-related methods.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import override + +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import ( # noqa: TC002 - used in @use_cache + Album, + Artist, + Track, +) + +from music_assistant.controllers.cache import use_cache +from music_assistant.providers.nicovideo.provider_mixins.base import ( + NicovideoMusicProviderMixinBase, +) + + +class NicovideoMusicProviderArtistMixin(NicovideoMusicProviderMixinBase): + """Artist-related methods for NicovideoMusicProvider.""" + + @override + @use_cache(3600 * 24 * 14) # Cache for 14 days + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get full artist details by id.""" + artist = await self.service_manager.user.get_user(prov_artist_id) + if not artist: + raise MediaNotFoundError(f"Artist with id {prov_artist_id} not found on nicovideo.") + return artist + + @override + async def get_library_artists( + self, + ) -> AsyncGenerator[Artist, None]: + """Retrieve library artists from the provider.""" + # Include followed artists if user is logged in + following_artists = await self.service_manager.user.get_own_followings() + for artist in following_artists: + yield artist + + @override + @use_cache(3600 * 24 * 14) # Cache for 14 days + async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: + """Get a list of all albums for the given artist (user's series).""" + return await self.service_manager.series.get_user_series(prov_artist_id) + + @override + @use_cache(3600 * 24 * 14) # Cache for 14 days + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + """Get newest 50 tracks of an artist.""" + return await self.service_manager.video.get_user_videos( + prov_artist_id, + page=1, + page_size=50, + ) diff --git a/music_assistant/providers/nicovideo/provider_mixins/base.py b/music_assistant/providers/nicovideo/provider_mixins/base.py new file mode 100644 index 0000000000..ac036ea30e --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/base.py @@ -0,0 +1,39 @@ +""" +NicovideoMusicProviderMixinBase: Interface definitions for _for_mixin patterns. + +This abstract base class defines the common interface for all nicovideo provider mixins: +- Abstract properties for shared resources (config, adapter) +- _for_mixin method signatures for delegation patterns +- Default implementations returning None for optional functionality +""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING + +from music_assistant.models.music_provider import MusicProvider + +if TYPE_CHECKING: + from music_assistant.providers.nicovideo.config import NicovideoConfig + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoMusicProviderMixinBase(MusicProvider): + """Interface for _for_mixin delegation patterns.""" + + @property + @abstractmethod + def nicovideo_config(self) -> NicovideoConfig: + """Get the config helper instance.""" + + @property + @abstractmethod + def service_manager(self) -> NicovideoServiceManager: + """Get the nicovideo service manager instance.""" + + async def handle_async_init_for_mixin(self) -> None: + """Handle async initialization for this mixin.""" + + async def unload_for_mixin(self, is_removed: bool = False) -> None: + """Handle unload/close for this mixin.""" diff --git a/music_assistant/providers/nicovideo/provider_mixins/core.py b/music_assistant/providers/nicovideo/provider_mixins/core.py new file mode 100644 index 0000000000..34177a4fa5 --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/core.py @@ -0,0 +1,84 @@ +""" +NicovideoMusicProviderCoreMixin: Core functionality not belonging to specific domains. + +This mixin handles core functionality that doesn't belong to any specific feature area: +- Instance management (adapter, config) +- Authentication and session management +- Provider lifecycle management (initialization/cleanup) +- Basic provider properties +""" + +from __future__ import annotations + +from typing import Any, override + +from music_assistant_models.errors import LoginFailed + +from music_assistant.providers.nicovideo.config import NicovideoConfig +from music_assistant.providers.nicovideo.provider_mixins.base import ( + NicovideoMusicProviderMixinBase, +) +from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoMusicProviderCoreMixin(NicovideoMusicProviderMixinBase): + """Core mixin handling instance management and provider lifecycle.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the core mixin.""" + super().__init__(*args, **kwargs) + self._nicovideo_config = NicovideoConfig(self) + self._service_manager = NicovideoServiceManager(self, self.nicovideo_config) + + @property + @override + def nicovideo_config(self) -> NicovideoConfig: + """Get the config helper instance.""" + return self._nicovideo_config + + @property + @override + def service_manager(self) -> NicovideoServiceManager: + """Get the nicovideo service manager instance.""" + return self._service_manager + + @property + @override + def is_streaming_provider(self) -> bool: + """Return True if the provider is a streaming provider.""" + # For streaming providers return True here but for local file based providers return False. + return True + + @override + async def handle_async_init_for_mixin(self) -> None: + """Handle async initialization of the provider.""" + try: + # Check if login credentials are provided + has_credentials = bool( + self.nicovideo_config.auth.user_session + or (self.nicovideo_config.auth.mail and self.nicovideo_config.auth.password) + ) + + if has_credentials: + # Try login if credentials are provided + login_success = await self.service_manager.auth.try_login() + if not login_success: + raise LoginFailed("Login failed with provided credentials") + self.service_manager.auth.start_periodic_relogin_task() + self.logger.debug("nicovideo provider initialized successfully with login") + else: + # No credentials provided - initialize without login + self.logger.debug("nicovideo provider initialized successfully without login") + except Exception as err: + self.logger.error("Failed to initialize nicovideo provider: %s", err) + raise + + @override + async def unload_for_mixin(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + try: + # Stop the periodic relogin task + self.service_manager.auth.stop_periodic_relogin_task() + self.logger.debug("nicovideo provider unloaded successfully") + except Exception as err: + self.logger.warning("Error during nicovideo provider unload: %s", err) diff --git a/music_assistant/providers/nicovideo/provider_mixins/explorer.py b/music_assistant/providers/nicovideo/provider_mixins/explorer.py new file mode 100644 index 0000000000..06bec9c117 --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/explorer.py @@ -0,0 +1,123 @@ +"""MixIn for NicovideoMusicProvider: search and recommendations methods.""" + +from __future__ import annotations + +from typing import override + +from music_assistant_models.enums import MediaType +from music_assistant_models.media_items import RecommendationFolder, SearchResults, Track +from music_assistant_models.unique_list import UniqueList + +from music_assistant.controllers.cache import use_cache +from music_assistant.providers.nicovideo.provider_mixins.base import ( + NicovideoMusicProviderMixinBase, +) + + +class NicovideoMusicProviderExplorerMixin(NicovideoMusicProviderMixinBase): + """Search and recommendations methods for NicovideoMusicProvider.""" + + @override + @use_cache(3600 * 3) # Cache for 3 hours + async def search( + self, + search_query: str, + media_types: list[MediaType], + limit: int = 5, + ) -> SearchResults: + """Perform search on musicprovider. + + :param search_query: Search query. + :param media_types: A list of media_types to include. + :param limit: Number of items to return in the search (per type). + """ + search_result = SearchResults() + + if MediaType.TRACK in media_types: + tracks = await self.service_manager.search.search_videos_by_keyword(search_query, limit) + search_result.tracks = tracks + + # Search for both playlists and albums in a single API call for efficiency + list_media_types = [mt for mt in media_types if mt in (MediaType.PLAYLIST, MediaType.ALBUM)] + + if list_media_types: + await self.service_manager.search.search_playlists_and_albums_by_keyword( + search_query, limit, search_result, list_media_types + ) + + return search_result + + @override + @use_cache(1800) # Cache for 30 minutes + async def recommendations(self) -> list[RecommendationFolder]: + """ + Get this provider's recommendations. + + Returns an actual (and often personalised) list of recommendations + from this provider for the user/account. + """ + recommendation_folders = [] + + # Main recommendations (default: 25 tracks) + main_recommendation_tracks = await self.service_manager.user.get_recommendations( + "video_recommendation_recommend", limit=25 + ) + if main_recommendation_tracks: + recommendation_folders.append( + RecommendationFolder( + item_id="nicovideo_recommendations", + name="nicovideo recommendations", + provider=self.lookup_key, + icon="mdi-star-circle-outline", + items=UniqueList(main_recommendation_tracks), + ) + ) + + # History Tracks (default: 50 tracks) + history_tracks = await self.service_manager.user.get_user_history(limit=50) + if history_tracks: + recommendation_folders.append( + RecommendationFolder( + item_id="nicovideo_history", + name="Recently watched (nicovideo history)", + provider=self.lookup_key, + icon="mdi-history", + items=UniqueList(history_tracks), + ) + ) + + # Following activities recommendations (default: 30 tracks) + following_activities_tracks = await self.service_manager.user.get_following_activities( + limit=30 + ) + if following_activities_tracks: + recommendation_folders.append( + RecommendationFolder( + item_id="nicovideo_following_activities", + name="New Tracks from Followed Users", + provider=self.lookup_key, + icon="mdi-account-plus-outline", + items=UniqueList(following_activities_tracks), + ) + ) + + # Like History recommendations (default: 50 tracks) + like_history_tracks = await self.service_manager.user.get_like_history(limit=50) + if like_history_tracks: + recommendation_folders.append( + RecommendationFolder( + item_id="nicovideo_like_history", + name="Recently liked (Like history)", + provider=self.lookup_key, + icon="mdi-heart-outline", + items=UniqueList(like_history_tracks), + ) + ) + + return recommendation_folders + + @override + @use_cache(3600 * 6) # Cache for 6 hours + async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]: + """Retrieve a dynamic list of similar tracks based on the provided track.""" + return await self.service_manager.user.get_similar_tracks(prov_track_id, limit) diff --git a/music_assistant/providers/nicovideo/provider_mixins/playlist.py b/music_assistant/providers/nicovideo/provider_mixins/playlist.py new file mode 100644 index 0000000000..423c76a43f --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/playlist.py @@ -0,0 +1,137 @@ +""" +nicovideo playlist mixin for Music Assistant. + +In this section, "Mylist" on niconico is treated as a playlist. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import override + +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import Playlist, Track # noqa: TC002 - used in @use_cache + +from music_assistant.controllers.cache import use_cache +from music_assistant.providers.nicovideo.provider_mixins.base import ( + NicovideoMusicProviderMixinBase, +) + + +class NicovideoMusicProviderPlaylistMixin(NicovideoMusicProviderMixinBase): + """Mixin class for handling playlist-related operations in NicovideoMusicProvider.""" + + @override + @use_cache(3600 * 24 * 14) # Cache for 14 days + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get full playlist details by id.""" + playlist_with_tracks = await self.service_manager.mylist.get_mylist_or_own_mylist( + prov_playlist_id, page_size=500 + ) + if not playlist_with_tracks: + raise MediaNotFoundError(f"Playlist with id {prov_playlist_id} not found on nicovideo.") + return playlist_with_tracks.playlist + + @override + @use_cache(3600 * 3) # Cache for 3 hours + async def get_playlist_tracks( + self, + prov_playlist_id: str, + page: int = 0, + ) -> list[Track]: + """Get all playlist tracks for given playlist id.""" + playlist_with_tracks = await self.service_manager.mylist.get_mylist_or_own_mylist( + prov_playlist_id, page_size=500, page=page + 1 + ) + + return playlist_with_tracks.tracks if playlist_with_tracks else [] + + @override + async def get_library_playlists( + self, + ) -> AsyncGenerator[Playlist, None]: + """Retrieve library playlists from the provider.""" + # Get own mylists (editable playlists) + own_mylists = await self.service_manager.mylist.get_own_mylists() + for mylist in own_mylists: + yield mylist + # Following mylists are not included in simplified config + return + + @override + async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None: + """Add track(s) to playlist.""" + for track_id in prov_track_ids: + success = await self.service_manager.mylist.add_mylist_item(prov_playlist_id, track_id) + if success: + self.logger.debug( + "Successfully added track %s to playlist %s", + track_id, + prov_playlist_id, + ) + else: + self.logger.warning( + "Failed to add track %s to playlist %s", track_id, prov_playlist_id + ) + + @override + async def remove_playlist_tracks( + self, prov_playlist_id: str, positions_to_remove: tuple[int, ...] + ) -> None: + """Remove track(s) from playlist.""" + # Get current playlist tracks to find track IDs at the specified positions + # Note: NicoNico's mylist does not allow duplicate entries of the same video_id + # within a single playlist. Therefore, mapping from 1-based positions to + # video_id is safe and uniquely identifies the target items. + playlist_tracks = await self.get_playlist_tracks(prov_playlist_id) + + # Extract track IDs to remove based on positions + # Note: positions_to_remove uses 1-based indexing, so convert to 0-based + track_ids_to_remove = [] + for position in positions_to_remove: + index = position - 1 # Convert from 1-based to 0-based indexing + if 0 <= index < len(playlist_tracks): + track_ids_to_remove.append(playlist_tracks[index].item_id) + + if not track_ids_to_remove: + self.logger.warning( + "No valid tracks found to remove from playlist %s", prov_playlist_id + ) + return + + success = await self.service_manager.mylist.remove_mylist_items( + prov_playlist_id, track_ids_to_remove + ) + if success: + self.logger.debug( + "Successfully removed %d tracks from playlist %s", + len(track_ids_to_remove), + prov_playlist_id, + ) + else: + self.logger.warning("Failed to remove tracks from playlist %s", prov_playlist_id) + + @override + async def create_playlist(self, name: str) -> Playlist: + """Create a new playlist on provider with given name.""" + # Create a new mylist using niconico.py + create_result = await self.service_manager.mylist.create_mylist( + name, description="Created by Music Assistant", is_public=False + ) + + if not create_result: + raise MediaNotFoundError(f"Failed to create playlist '{name}' on nicovideo.") + + # Get the created mylist details + mylist_id = str(create_result.mylist.id_) + playlist_with_tracks = await self.service_manager.mylist.get_own_mylist( + mylist_id, page_size=1 + ) + + if not playlist_with_tracks: + raise MediaNotFoundError( + f"Failed to retrieve created playlist '{name}' from nicovideo." + ) + + self.logger.info("Successfully created playlist '%s' with ID %s", name, mylist_id) + return playlist_with_tracks.playlist diff --git a/music_assistant/providers/nicovideo/provider_mixins/track.py b/music_assistant/providers/nicovideo/provider_mixins/track.py new file mode 100644 index 0000000000..1099288ced --- /dev/null +++ b/music_assistant/providers/nicovideo/provider_mixins/track.py @@ -0,0 +1,99 @@ +"""MixIn for NicovideoMusicProvider: track-related methods.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, override + +import shortuuid +from aiohttp import web +from music_assistant_models.enums import ContentType, MediaType +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import ( + AudioFormat, + Track, +) + +from music_assistant.controllers.cache import use_cache +from music_assistant.helpers.ffmpeg import get_ffmpeg_stream +from music_assistant.providers.nicovideo.converters.stream import NicovideoStreamData +from music_assistant.providers.nicovideo.helpers.hls_seek_optimizer import ( + HLSSeekOptimizer, +) +from music_assistant.providers.nicovideo.provider_mixins.base import ( + NicovideoMusicProviderMixinBase, +) + +if TYPE_CHECKING: + from music_assistant_models.streamdetails import StreamDetails + + +class NicovideoMusicProviderTrackMixin(NicovideoMusicProviderMixinBase): + """Track-related methods for NicovideoMusicProvider.""" + + @override + @use_cache(3600 * 24 * 14) # Cache for 14 days + async def get_track(self, prov_track_id: str) -> Track: + """Get full track details by id.""" + track = await self.service_manager.video.get_video(prov_track_id) + if not track: + raise MediaNotFoundError(f"Track with id {prov_track_id} not found on nicovideo.") + return track + + @override + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Get stream details (streaming URL and format) for given item.""" + if media_type is not MediaType.TRACK: + raise MediaNotFoundError(f"Media type {media_type} is not supported for stream details") + return await self.service_manager.video.get_stream_details(item_id) + + @override + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Get audio stream with dynamic playlist generation for optimized seeking. + + Args: + streamdetails: Stream details containing domand_bid and parsed_playlist in data field + seek_position: Position to seek to in seconds + + Yields: + Audio data bytes + """ + if not isinstance(streamdetails.data, NicovideoStreamData): + msg = f"Invalid stream data type: {type(streamdetails.data)}" + raise TypeError(msg) + + hls_data = streamdetails.data + processor = HLSSeekOptimizer(hls_data) + optimized_context = processor.create_stream_context(seek_position) + + # Register dynamic route to serve HLS playlist + route_id = shortuuid.random(20) + route_path = f"/nicovideo_m3u8/{route_id}.m3u8" + playlist_url = f"{self.mass.streams.base_url}{route_path}" + + async def _serve_hls_playlist(_request: web.Request) -> web.Response: + """Serve dynamically generated HLS playlist (.m3u8) file for seeking.""" + return web.Response( + text=optimized_context.dynamic_playlist_text, + content_type="application/vnd.apple.mpegurl", + ) + + unregister = self.mass.streams.register_dynamic_route(route_path, _serve_hls_playlist) + + try: + async for chunk in get_ffmpeg_stream( + audio_input=playlist_url, + input_format=streamdetails.audio_format, + output_format=AudioFormat( + content_type=ContentType.NUT, + sample_rate=streamdetails.audio_format.sample_rate, + bit_depth=streamdetails.audio_format.bit_depth, + channels=streamdetails.audio_format.channels, + ), + extra_input_args=optimized_context.extra_input_args, + ): + yield chunk + finally: + unregister() diff --git a/music_assistant/providers/nicovideo/services/__init__.py b/music_assistant/providers/nicovideo/services/__init__.py new file mode 100644 index 0000000000..12a800a3ee --- /dev/null +++ b/music_assistant/providers/nicovideo/services/__init__.py @@ -0,0 +1,14 @@ +""" +nicovideo services package. + +Services Layer: API integration and data transformation coordination +Coordinates API calls through niconico.py, manages rate limiting, and delegates data transformation. +""" + +from __future__ import annotations + +from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + +__all__ = [ + "NicovideoServiceManager", +] diff --git a/music_assistant/providers/nicovideo/services/auth.py b/music_assistant/providers/nicovideo/services/auth.py new file mode 100644 index 0000000000..5e0360614a --- /dev/null +++ b/music_assistant/providers/nicovideo/services/auth.py @@ -0,0 +1,148 @@ +"""Authentication service for nicovideo.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from niconico.exceptions import LoginFailureError + +from music_assistant.providers.nicovideo.helpers import log_verbose +from music_assistant.providers.nicovideo.services.base import NicovideoBaseService + +if TYPE_CHECKING: + from asyncio import TimerHandle + + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoAuthService(NicovideoBaseService): + """Handles authentication and session management for nicovideo.""" + + def __init__(self, service_manager: NicovideoServiceManager) -> None: + """Initialize the NicovideoAuthService with a reference to the parent service manager.""" + super().__init__(service_manager) + self._periodic_relogin_task: TimerHandle | None = None + + @property + def is_logged_in(self) -> bool: + """Check if the user is logged in to niconico.""" + return self.niconico_py_client.logined + + async def try_login(self) -> bool: + """Attempt to login to niconico with the configured credentials.""" + if self.is_logged_in: + return True + + config = self.nicovideo_config + username = config.auth.mail + password = config.auth.password + mfa = config.auth.mfa + user_session = config.auth.user_session + max_retries = 3 + retry_delay_seconds = 1 + async with self.service_manager.niconico_api_throttler.bypass(): + for attempt in range(max_retries): + try: + self.logger.debug( + "Trying to log in... (Number of attempts: %d/%d)", + attempt + 1, + max_retries, + ) + if user_session: + self.logger.debug("Using user_session for login.") + await asyncio.to_thread( + self.niconico_py_client.login_with_session, + str(user_session), + ) + else: + self.logger.debug("Using mail and password for login.") + if not username or not password: + self.logger.debug( + "Username and password are not set in the configuration.", + ) + return False + await asyncio.to_thread( + self.niconico_py_client.login_with_mail, + str(username), + str(password), + str(mfa) if mfa else None, + ) + self.logger.info("Successfully authenticated with Nicovideo!") + # Clear MFA code after successful use (one-time password should not be reused) + if mfa: + config.auth.clear_mfa_code() + session = self.niconico_py_client.get_user_session() + if session: + config.auth.save_user_session(session) + log_verbose( + self.logger, + "Saved user session for future logins (length: %d chars)", + len(session), + ) + return True + except LoginFailureError as err: + if user_session: + user_session = None # Clear session on failure + self.logger.warning("Login with user_session failed: %s", err) + else: + self.logger.error("Login with mail and password failed: %s", err) + return False + except Exception as e: + if ( + "Name or service not known" in str(e) + or "Max retries exceeded" in str(e) + or "ConnectionError" in str(e) + ): + self.logger.warning( + f"Network or DNS error occurred: {e}. " + f"Retrying in {retry_delay_seconds} seconds..." + ) + await asyncio.sleep(retry_delay_seconds) + else: + self.logger.error("An unexpected error has occurred.: %s", e) + return False + self.logger.error( + f"Could not login after exceeding the maximum number of retries ({max_retries})." + ) + return False + + async def try_logout(self) -> None: + """Log out from the niconico service.""" + if self.niconico_py_client: + if self.is_logged_in: + await asyncio.to_thread(self.niconico_py_client.logout) + self.service_manager.reset_niconico_py_client() + + def start_periodic_relogin_task(self) -> None: + """Start the periodic re-login task.""" + # Cancel existing task if any + self.stop_periodic_relogin_task() + self._periodic_relogin_task = self.service_manager.mass.call_later( + 30 * 24 * 60 * 60, self._schedule_periodic_relogin + ) + + def stop_periodic_relogin_task(self) -> None: + """Stop the periodic re-login task.""" + if self._periodic_relogin_task and not self._periodic_relogin_task.cancelled(): + self._periodic_relogin_task.cancel() + self._periodic_relogin_task = None + + async def _schedule_periodic_relogin(self) -> None: + """Periodic re-login every 30 days.""" + try: + self.logger.debug("Performing periodic re-login to refresh the session.") + + config = self.nicovideo_config + if not (config.auth.mail or config.auth.password): + self.logger.debug("No login credentials provided, skipping periodic re-login.") + self.start_periodic_relogin_task() + return + + await self.try_logout() + await asyncio.sleep(3) # Short delay to ensure logout completes + await self.try_login() + self.start_periodic_relogin_task() + except asyncio.CancelledError: + self.logger.debug("Periodic relogin task was cancelled.") + raise diff --git a/music_assistant/providers/nicovideo/services/base.py b/music_assistant/providers/nicovideo/services/base.py new file mode 100644 index 0000000000..18eb671dc3 --- /dev/null +++ b/music_assistant/providers/nicovideo/services/base.py @@ -0,0 +1,36 @@ +"""Base service for nicovideo.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from niconico import NicoNico + + from music_assistant.providers.nicovideo.config import NicovideoConfig + from music_assistant.providers.nicovideo.converters import NicovideoConverterManager + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoBaseService: + """Base service for MusicAssistant integration classes.""" + + def __init__(self, service_manager: NicovideoServiceManager) -> None: + """Initialize the NicovideoBaseService with a reference to the parent service manager.""" + self.service_manager = service_manager + self.logger = service_manager.logger.getChild(self.__class__.__name__) + + @property + def nicovideo_config(self) -> NicovideoConfig: + """Get the config helper instance.""" + return self.service_manager.nicovideo_config + + @property + def converter_manager(self) -> NicovideoConverterManager: + """Get the main converter instance.""" + return self.service_manager.converter_manager + + @property + def niconico_py_client(self) -> NicoNico: + """Get the niconico.py client instance.""" + return self.service_manager.niconico_py_client diff --git a/music_assistant/providers/nicovideo/services/manager.py b/music_assistant/providers/nicovideo/services/manager.py new file mode 100644 index 0000000000..f39641c33b --- /dev/null +++ b/music_assistant/providers/nicovideo/services/manager.py @@ -0,0 +1,193 @@ +""" +Manager service for niconico API integration with MusicAssistant. + +Services Layer: API integration and data transformation coordination +- Coordinates API calls through niconico.py adapter +- Manages authentication and session management +- Handles API rate limiting and throttling +- Delegates data transformation to converters +""" + +from __future__ import annotations + +import asyncio +import inspect +from collections.abc import Callable +from typing import TYPE_CHECKING + +from niconico import NicoNico +from niconico.exceptions import LoginFailureError, LoginRequiredError, PremiumRequiredError +from pydantic import ValidationError + +from music_assistant.helpers.throttle_retry import ThrottlerManager +from music_assistant.models.music_provider import MusicProvider +from music_assistant.providers.nicovideo.constants import ApiPriority +from music_assistant.providers.nicovideo.converters.manager import ( + NicovideoConverterManager, +) +from music_assistant.providers.nicovideo.helpers import log_verbose +from music_assistant.providers.nicovideo.services.auth import NicovideoAuthService +from music_assistant.providers.nicovideo.services.mylist import NicovideoMylistService +from music_assistant.providers.nicovideo.services.search import NicovideoSearchService +from music_assistant.providers.nicovideo.services.series import NicovideoSeriesService +from music_assistant.providers.nicovideo.services.user import NicovideoUserService +from music_assistant.providers.nicovideo.services.video import NicovideoVideoService + +if TYPE_CHECKING: + from music_assistant.providers.nicovideo.config import NicovideoConfig + + +class NicovideoServiceManager: + """Central manager for all niconico services and MusicAssistant integration.""" + + def __init__(self, provider: MusicProvider, nicovideo_config: NicovideoConfig) -> None: + """Initialize service manager with provider and config.""" + self.provider = provider + self.nicovideo_config = nicovideo_config + self.mass = provider.mass + self.reset_niconico_py_client() + + self.niconico_api_throttler = ThrottlerManager(rate_limit=5, period=1) + # Low priority throttler for background tag updates (slower rate) + self.niconico_api_throttler_low_priority = ThrottlerManager(rate_limit=1, period=1) + + self.logger = provider.logger + + # Initialize services for different functionality + self.auth = NicovideoAuthService(self) + self.video = NicovideoVideoService(self) + self.series = NicovideoSeriesService(self) + self.mylist = NicovideoMylistService(self) + self.search = NicovideoSearchService(self) + self.user = NicovideoUserService(self) + + # Initialize converter + self.converter_manager = NicovideoConverterManager(provider, self.logger) + + def reset_niconico_py_client(self) -> None: + """Reset the niconico.py client instance.""" + self.niconico_py_client = NicoNico() + + def _safe_summarize(self, value: object) -> str: + """Summarize a value safely for logs (mask secrets, truncate long).""" + try: + s = str(value) + except Exception: + return "" + low = s.lower() + if any(k in low for k in ("cookie", "token", "session", "password")): + return "" + return (s[:200] + "…") if len(s) > 200 else s + + def _summarize_call_args( + self, args: tuple[object, ...], kwargs: dict[str, object] + ) -> tuple[str, str]: + """Create safe summaries for positional and keyword args.""" + try: + arg_summary = ", ".join(self._safe_summarize(a) for a in args) + except Exception: + arg_summary = "" + try: + kw_summary = ", ".join(f"{k}={self._safe_summarize(v)}" for k, v in kwargs.items()) + except Exception: + kw_summary = "" + return arg_summary, kw_summary + + def _extract_caller_info(self) -> str: + """Extract best-effort caller info file:function:line for diagnostics.""" + frame = inspect.currentframe() + caller_info = "unknown" + try: + caller_frame = None + if frame and frame.f_back and frame.f_back.f_back: + caller_frame = frame.f_back.f_back # Skip this method and acquire context + if caller_frame: + caller_filename = caller_frame.f_code.co_filename + caller_function = caller_frame.f_code.co_name + caller_line = caller_frame.f_lineno + filename = caller_filename.rsplit("/", 1)[-1] + caller_info = f"{filename}:{caller_function}:{caller_line}" + except Exception: + caller_info = "stack_inspection_failed" + finally: + del frame # Prevent reference cycles + return caller_info + + def _log_call_exception(self, operation: str, err: Exception) -> None: + """Log exceptions with classification and caller info.""" + caller_info = self._extract_caller_info() + if isinstance(err, LoginRequiredError): + self.logger.debug( + "Authentication required for %s called from %s: %s", operation, caller_info, err + ) + elif isinstance(err, PremiumRequiredError): + self.logger.warning( + "Premium account required for %s called from %s: %s", operation, caller_info, err + ) + elif isinstance(err, LoginFailureError): + self.logger.warning( + "Login failed for %s called from %s: %s", operation, caller_info, err + ) + elif isinstance(err, (ConnectionError, TimeoutError)): + self.logger.warning("Network error %s called from %s: %s", operation, caller_info, err) + elif isinstance(err, ValidationError): + try: + detailed_errors = err.errors() + self.logger.warning( + "Validation error %s called from %s: %s\nDetailed errors: %s", + operation, + caller_info, + err, + detailed_errors, + ) + except Exception: + self.logger.warning("Error %s called from %s: %s", operation, caller_info, err) + else: + self.logger.warning("Error %s called from %s: %s", operation, caller_info, err) + + async def _call_with_throttler[T, **P]( + self, + func: Callable[P, T], + *args: P.args, + **kwargs: P.kwargs, + ) -> T | None: + """Call function with API throttling.""" + return await self._call_with_throttler_with_priority( + ApiPriority.HIGH, func, *args, **kwargs + ) + + async def _call_with_throttler_with_priority[T, **P]( + self, + priority: ApiPriority, + func: Callable[P, T], + *args: P.args, + **kwargs: P.kwargs, + ) -> T | None: + """Call function with API throttling (unified method with priority support).""" + if priority == ApiPriority.HIGH: + throttler = self.niconico_api_throttler + throttler_name = "high_priority" + else: # ApiPriority.LOW + throttler = self.niconico_api_throttler_low_priority + throttler_name = "low_priority" + + operation = func.__name__ if hasattr(func, "__name__") else "unknown_function" + arg_summary, kw_summary = self._summarize_call_args(args, kwargs) + log_verbose( + self.logger, + "Acquire %s throttler for %s(%s%s%s)", + throttler_name, + operation, + arg_summary, + ", " if arg_summary and kw_summary else "", + kw_summary, + ) + + try: + async with throttler.acquire(): + result = await asyncio.to_thread(func, *args, **kwargs) + log_verbose(self.logger, "%s succeeded (priority=%s)", operation, throttler_name) + return result + except Exception as err: + self._log_call_exception(operation, err) + return None diff --git a/music_assistant/providers/nicovideo/services/mylist.py b/music_assistant/providers/nicovideo/services/mylist.py new file mode 100644 index 0000000000..44fca3ccad --- /dev/null +++ b/music_assistant/providers/nicovideo/services/mylist.py @@ -0,0 +1,111 @@ +"""Mylist adapter for nicovideo.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant.providers.nicovideo.helpers import PlaylistWithTracks +from music_assistant.providers.nicovideo.services.base import NicovideoBaseService + +if TYPE_CHECKING: + from music_assistant_models.media_items import Playlist + from niconico.objects.nvapi import CreateMylistData + + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoMylistService(NicovideoBaseService): + """Handles mylist related operations for nicovideo.""" + + def __init__(self, adapter: NicovideoServiceManager) -> None: + """Initialize NicovideoMylistService with reference to parent adapter.""" + super().__init__(adapter) + + async def get_own_mylists(self) -> list[Playlist]: + """Get own mylists and convert them.""" + results = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_own_mylists + ) + if results is None: + return [] + return [self.converter_manager.playlist.convert_by_mylist(entry) for entry in results] + + async def get_mylist_or_own_mylist( + self, mylist_id: str, page_size: int = 500, page: int = 1 + ) -> PlaylistWithTracks | None: + """Get mylist with fallback to own_mylist for private mylists.""" + # Try public mylist first + playlist_with_tracks = await self._get_mylist(mylist_id, page_size=page_size, page=page) + if not playlist_with_tracks: + # Fallback to own mylist (for private mylists) + playlist_with_tracks = await self.get_own_mylist( + mylist_id, page_size=page_size, page=page + ) + return playlist_with_tracks + + async def get_own_mylist( + self, mylist_id: str, page_size: int = 500, page: int = 1 + ) -> PlaylistWithTracks | None: + """Get own mylist details and convert as Playlist.""" + mylist = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_own_mylist, + mylist_id, + page_size=page_size, + page=page, + ) + if not mylist: + return None + playlist_with_tracks = self.converter_manager.playlist.convert_with_tracks_by_mylist(mylist) + self._update_positions_in_playlist(playlist_with_tracks) + return playlist_with_tracks + + async def add_mylist_item(self, mylist_id: str, video_id: str) -> bool: + """Add a video to mylist.""" + result = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.add_mylist_item, + mylist_id, + video_id, + ) + return bool(result) + + async def remove_mylist_items(self, mylist_id: str, video_ids: list[str]) -> bool: + """Remove videos from mylist.""" + result = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.remove_mylist_items, + mylist_id, + video_ids, + ) + return bool(result) + + async def create_mylist( + self, name: str, description: str = "", is_public: bool = False + ) -> CreateMylistData | None: + """Create a new mylist.""" + return await self.service_manager._call_with_throttler( + self.niconico_py_client.user.create_mylist, + name, + description=description, + is_public=is_public, + ) + + async def _get_mylist( + self, mylist_id: str, page_size: int = 500, page: int = 1 + ) -> PlaylistWithTracks | None: + """Get mylist details and convert as Playlist.""" + mylist = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.get_mylist, + mylist_id, + page_size=page_size, + page=page, + ) + if not mylist: + return None + playlist_with_tracks = self.converter_manager.playlist.convert_with_tracks_by_mylist(mylist) + self._update_positions_in_playlist(playlist_with_tracks) + return playlist_with_tracks + + def _update_positions_in_playlist(self, playlist: PlaylistWithTracks) -> None: + """Update positions in playlist tracks.""" + # Ensure tracks have position set (1-based) + for index, track in enumerate(playlist.tracks, start=1): + track.position = index diff --git a/music_assistant/providers/nicovideo/services/search.py b/music_assistant/providers/nicovideo/services/search.py new file mode 100644 index 0000000000..a789966a0a --- /dev/null +++ b/music_assistant/providers/nicovideo/services/search.py @@ -0,0 +1,153 @@ +"""Search adapter for nicovideo.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import MediaType + +if TYPE_CHECKING: + from music_assistant_models.media_items import Album, Playlist, Track +from niconico.objects.video.search import ( + EssentialMylist, + EssentialSeries, + VideoSearchSortKey, + VideoSearchSortOrder, +) + +from music_assistant.providers.nicovideo.services.base import NicovideoBaseService + +if TYPE_CHECKING: + from music_assistant_models.media_items import SearchResults + + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoSearchService(NicovideoBaseService): + """Handles search related operations for nicovideo.""" + + def __init__(self, adapter: NicovideoServiceManager) -> None: + """Initialize NicovideoSearchService with reference to parent adapter.""" + super().__init__(adapter) + + async def search_playlists_and_albums_by_keyword( + self, + search_query: str, + limit: int, + search_result: SearchResults, + media_types: list[MediaType], + ) -> None: + """Search for playlists (mylists) and albums (series) by keyword.""" + if not media_types: + return + + search_playlists = MediaType.PLAYLIST in media_types + search_albums = MediaType.ALBUM in media_types + + playlists_to_add = [] + albums_to_add = [] + + # Search for mylists and series separately to work around API bug + # where specifying both types returns only series + if search_playlists: + mylists = await self._search_mylists_by_keyword(search_query, limit) + playlists_to_add.extend(mylists) + + if search_albums: + albums = await self._search_series_by_keyword(search_query, limit) + albums_to_add.extend(albums) + + # Add items to search result + if playlists_to_add: + current_playlists = list(search_result.playlists) + current_playlists.extend(playlists_to_add) + search_result.playlists = current_playlists + if albums_to_add: + current_albums = list(search_result.albums) + current_albums.extend(albums_to_add) + search_result.albums = current_albums + + async def _search_mylists_by_keyword(self, search_query: str, limit: int) -> list[Playlist]: + """Search for mylists by keyword.""" + list_search_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.search.search_lists, + search_query, + page_size=limit, + types=["mylist"], + ) + + if not list_search_data: + return [] + + playlists = [] + for item in list_search_data.items: + if isinstance(item, EssentialMylist): + playlists.append(self.converter_manager.playlist.convert_by_mylist(item)) + + return playlists + + async def _search_series_by_keyword(self, search_query: str, limit: int) -> list[Album]: + """Search for series by keyword.""" + list_search_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.search.search_lists, + search_query, + page_size=limit, + types=["series"], + ) + + if not list_search_data: + return [] + + albums = [] + for item in list_search_data.items: + if isinstance(item, EssentialSeries): + albums.append(self.converter_manager.album.convert_by_series(item)) + + return albums + + async def search_videos_by_keyword(self, search_query: str, limit: int) -> list[Track]: + """Search for videos by keyword.""" + video_search_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.search.search_videos_by_keyword, + search_query, + page_size=limit, + search_by_user=True, + ) + if not video_search_data: + return [] + + tracks = [] + for item in video_search_data.items: + if item.id_: + track = self.converter_manager.track.convert_by_essential_video(item) + if track: + tracks.append(track) + return tracks + + async def search_videos_by_tag( + self, + tag: str, + limit: int, + sort: VideoSearchSortKey, + sort_order: VideoSearchSortOrder, + ) -> list[Track]: + """Search for videos by tags with specified sort order.""" + tracks = [] + # Search for each tag separately since search_videos_by_tag only accepts one tag + video_search_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.search.search_videos_by_tag, + tag, + page_size=limit, + sort_key=sort, + sort_order=sort_order, + search_by_user=True, + ) + + if video_search_data: + for item in video_search_data.items: + if item.id_: + track = self.converter_manager.track.convert_by_essential_video(item) + if track: + tracks.append(track) + + return tracks[:limit] # Limit total results diff --git a/music_assistant/providers/nicovideo/services/series.py b/music_assistant/providers/nicovideo/services/series.py new file mode 100644 index 0000000000..bb98d57d0f --- /dev/null +++ b/music_assistant/providers/nicovideo/services/series.py @@ -0,0 +1,97 @@ +"""Series adapter for nicovideo.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant.providers.nicovideo.helpers import AlbumWithTracks +from music_assistant.providers.nicovideo.services.base import NicovideoBaseService + +if TYPE_CHECKING: + from music_assistant_models.media_items import Album + + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoSeriesService(NicovideoBaseService): + """Handles series related operations for nicovideo.""" + + def __init__(self, adapter: NicovideoServiceManager) -> None: + """Initialize NicovideoSeriesService with reference to parent adapter.""" + super().__init__(adapter) + + async def get_series_or_own_series( + self, series_id: str, page: int = 1, page_size: int = 100 + ) -> AlbumWithTracks | None: + """Get series details with fallback to own series for private series.""" + # Try public series first + album_with_tracks = await self._get_series(series_id, page=page, page_size=page_size) + if not album_with_tracks: + # Fallback to own series (for private series) + album_with_tracks = await self._get_own_series_detail( + series_id, page=page, page_size=page_size + ) + return album_with_tracks + + async def get_user_series( + self, user_id: str, page: int = 1, page_size: int = 100 + ) -> list[Album]: + """Get user series and convert as Album list.""" + user_series_items = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_user_series, + user_id, + page=page, + page_size=page_size, + ) + if not user_series_items: + return [] + + return [ + self.converter_manager.album.convert_by_series(series_item) + for series_item in user_series_items + ] + + async def get_own_series(self, page: int = 1, page_size: int = 100) -> list[Album]: + """Get own series list and convert as Album list.""" + user_series_items = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_own_series, + page=page, + page_size=page_size, + ) + if not user_series_items: + return [] + + return [ + self.converter_manager.album.convert_by_series(series_item) + for series_item in user_series_items + ] + + async def _get_series( + self, series_id: str, page: int = 1, page_size: int = 100 + ) -> AlbumWithTracks | None: + """Get series details and convert as AlbumWithTracks.""" + series_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.get_series, + series_id, + page=page, + page_size=page_size, + ) + if not series_data: + return None + + return self.converter_manager.album.convert_series_to_album_with_tracks(series_data) + + async def _get_own_series_detail( + self, series_id: str, page: int = 1, page_size: int = 100 + ) -> AlbumWithTracks | None: + """Get own series details and convert as AlbumWithTracks.""" + series_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_own_series_detail, + series_id, + page=page, + page_size=page_size, + ) + if not series_data: + return None + + return self.converter_manager.album.convert_series_to_album_with_tracks(series_data) diff --git a/music_assistant/providers/nicovideo/services/user.py b/music_assistant/providers/nicovideo/services/user.py new file mode 100644 index 0000000000..cdf45d05ab --- /dev/null +++ b/music_assistant/providers/nicovideo/services/user.py @@ -0,0 +1,207 @@ +"""User adapter for nicovideo.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant.providers.nicovideo.constants import SENSITIVE_CONTENTS +from music_assistant.providers.nicovideo.services.base import NicovideoBaseService + +if TYPE_CHECKING: + from typing import Literal + + from music_assistant_models.media_items import Artist, Playlist, Track + from niconico.objects.nvapi import FollowingMylistItem + + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + +# Import at runtime for isinstance checks +from niconico.objects.video import EssentialVideo + + +class NicovideoUserService(NicovideoBaseService): + """Get user details from nicovideo.""" + + def __init__(self, service_manager: NicovideoServiceManager) -> None: + """Initialize NicovideoUserService with reference to parent service manager.""" + super().__init__(service_manager) + + async def get_user(self, user_id: str) -> Artist | None: + """Get user details as Artist.""" + user = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_user, user_id + ) + return self.converter_manager.artist.convert_by_owner_or_user(user) if user else None + + async def get_recommendations( + self, + recipe_id: Literal[ + "video_watch_recommendation", "video_recommendation_recommend", "video_top_recommend" + ] = "video_watch_recommendation", + limit: int = 25, + ) -> list[Track]: + """Get recommendations from nicovideo.""" + recommendations = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_recommendations, + recipe_id, + limit=limit, + sensitive_contents=SENSITIVE_CONTENTS, + ) + if not recommendations or not recommendations.items: + return [] + + tracks = [] + for item in recommendations.items: + # Only process video content, skip user recommendations + if item.content_type != "video": + continue + + # Type check to ensure content is EssentialVideo + if isinstance(item.content, EssentialVideo): + track = self.converter_manager.track.convert_by_essential_video(item.content) + if track: + tracks.append(track) + return tracks + + async def get_similar_tracks(self, track_id: str, limit: int = 25) -> list[Track]: + """Get tracks similar to the given track.""" + recommendation_api_item = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_recommendations, + "video_watch_recommendation", + video_id=track_id, + limit=limit, + sensitive_contents=SENSITIVE_CONTENTS, + ) + if not recommendation_api_item or not recommendation_api_item.items: + return [] + + tracks = [] + for item in recommendation_api_item.items: + # Only process video content + if item.content_type != "video": + continue + + # Type check to ensure content is EssentialVideo + if isinstance(item.content, EssentialVideo): + track = self.converter_manager.track.convert_by_essential_video(item.content) + if track: + tracks.append(track) + return tracks + + async def get_like_history(self, limit: int = 25) -> list[Track]: + """Get user's like history from nicovideo.""" + # Calculate page_size based on limit + page_size = min(limit, 25) # API max is 25 for like history + like_history = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.get_like_history, + page_size=page_size, + page=1, + ) + if not like_history or not like_history.items: + return [] + + tracks = [] + for item in like_history.items: + track = self.converter_manager.track.convert_by_essential_video(item.video) + if track: + tracks.append(track) + return tracks + + async def get_user_history(self, limit: int = 30) -> list[Track]: + """Get user's history from nicovideo.""" + # Calculate page_size based on limit + page_size = min(limit, 100) # API max is 100 + history = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.get_history, + page_size=page_size, + page=1, + ) + if not history or not history.items: + return [] + + tracks = [] + for item in history.items: + track = self.converter_manager.track.convert_by_essential_video(item.video) + if track: + tracks.append(track) + return tracks + + async def get_own_videos(self, limit: int = 100) -> list[Track]: + """Get user's own uploaded videos from nicovideo.""" + # Calculate page_size based on limit + page_size = min(limit, 100) # API max likely 100 + own_videos = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_own_videos, + page_size=page_size, + page=1, + sensitive_contents=SENSITIVE_CONTENTS, + ) + if not own_videos or not own_videos.items: + return [] + + tracks = [] + for item in own_videos.items: + track = self.converter_manager.track.convert_by_essential_video(item.essential) + if track: + tracks.append(track) + return tracks + + async def get_following_activities(self, limit: int = 50) -> list[Track]: + """Get latest activities from followed users.""" + feed_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_following_activities, + endpoint="video", + context="header_timeline", + cursor=None, + ) + + if not feed_data: + return [] + + # Convert activities directly to tracks using lightweight conversion + tracks = [] + for activity in feed_data.activities: + if activity.content and activity.content.video and "video" in activity.kind.lower(): + track = self.converter_manager.track.convert_by_activity(activity) + if track: + tracks.append(track) + if len(tracks) >= limit: + break + + return tracks + + async def get_following_mylists(self) -> list[FollowingMylistItem]: + """Get mylists the user is following.""" + # Following mylists are not included in simplified config + return [] + + async def get_own_followings(self) -> list[Artist]: + """Get users the current user is following and convert them to Artists.""" + # Own followings are not included in simplified config + return [] + + async def follow_user(self, user_id: str) -> bool: + """Follow a user.""" + result = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.follow_user, user_id + ) + return bool(result) + + async def unfollow_user(self, user_id: str) -> bool: + """Unfollow a user.""" + result = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.unfollow_user, user_id + ) + return bool(result) + + async def get_following_playlists(self) -> list[Playlist]: + """Get playlists from users you follow, converted to Music Assistant format.""" + following_mylists = await self.get_following_mylists() + if not following_mylists: + return [] + + playlists = [] + for mylist in following_mylists: + playlist = self.converter_manager.playlist.convert_following_by_mylist(mylist) + playlists.append(playlist) + return playlists diff --git a/music_assistant/providers/nicovideo/services/video.py b/music_assistant/providers/nicovideo/services/video.py new file mode 100644 index 0000000000..f59fdeb13b --- /dev/null +++ b/music_assistant/providers/nicovideo/services/video.py @@ -0,0 +1,194 @@ +"""Video service for nicovideo.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urljoin + +from music_assistant_models.errors import InvalidDataError, UnplayableMediaError + +from music_assistant.providers.nicovideo.constants import ( + DOMAND_BID_COOKIE_NAME, + NICOVIDEO_USER_AGENT, + SENSITIVE_CONTENTS, +) +from music_assistant.providers.nicovideo.converters.stream import ( + StreamConversionData, +) +from music_assistant.providers.nicovideo.services.base import NicovideoBaseService + +if TYPE_CHECKING: + from music_assistant_models.media_items import Track + from music_assistant_models.streamdetails import StreamDetails + from niconico.objects.video.watch import WatchData, WatchMediaDomandAudio + + from music_assistant.providers.nicovideo.services.manager import NicovideoServiceManager + + +class NicovideoVideoService(NicovideoBaseService): + """Handles video and stream related operations for nicovideo.""" + + def __init__(self, service_manager: NicovideoServiceManager) -> None: + """Initialize NicovideoVideoService with reference to parent service manager.""" + super().__init__(service_manager) + + async def get_user_videos( + self, user_id: str, page: int = 1, page_size: int = 50 + ) -> list[Track]: + """Get user videos and convert as Track list.""" + user_video_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.user.get_user_videos, + user_id, + page=page, + page_size=page_size, + sensitive_contents=SENSITIVE_CONTENTS, + ) + if not user_video_data or not user_video_data.items: + return [] + tracks = [] + for item in user_video_data.items: + track = self.converter_manager.track.convert_by_essential_video(item.essential) + if track: + tracks.append(track) + return tracks + + async def get_video(self, video_id: str) -> Track | None: + """Get video details using WatchData and convert as Track.""" + watch_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.watch.get_watch_data, video_id + ) + + if watch_data: + return self.converter_manager.track.convert_by_watch_data(watch_data) + + return None + + async def get_stream_details(self, video_id: str) -> StreamDetails: + """Get StreamDetails for a video using WatchData and converter.""" + conversion_data = await self._prepare_conversion_data(video_id) + return self.converter_manager.stream.convert_from_conversion_data(conversion_data) + + async def like_video(self, video_id: str) -> bool: + """Like a video.""" + result = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.like_video, video_id + ) + return bool(result) + + async def _prepare_conversion_data(self, video_id: str) -> StreamConversionData: + """Prepare StreamConversionData for a video.""" + # 1. Fetch watch data + watch_data = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.watch.get_watch_data, video_id + ) + if not watch_data: + raise UnplayableMediaError("Failed to fetch watch data") + + # 2. Select best available audio + selected_audio = self._select_best_audio(watch_data) + + # 3. Get HLS URL for selected audio + hls_url = await self._get_hls_url(watch_data, selected_audio) + + # 4. Get domand_bid for ffmpeg headers + domand_bid = self.niconico_py_client.session.cookies.get(DOMAND_BID_COOKIE_NAME) + if not domand_bid: + raise UnplayableMediaError("Failed to fetch domand_bid") + + # 5. Fetch HLS playlist text + playlist_text = await self._fetch_media_playlist_text(hls_url, domand_bid) + + # 6. Return conversion data + return StreamConversionData( + watch_data=watch_data, + selected_audio=selected_audio, + hls_url=hls_url, + domand_bid=domand_bid, + hls_playlist_text=playlist_text, + ) + + def _select_best_audio(self, watch_data: WatchData) -> WatchMediaDomandAudio: + """Select the best available audio from WatchData.""" + best_audio = None + best_quality = -1 + for audio in watch_data.media.domand.audios: + if audio.is_available and audio.quality_level > best_quality: + best_audio = audio + best_quality = audio.quality_level + + if not best_audio: + raise UnplayableMediaError("No available audio found") + + return best_audio + + async def _get_hls_url( + self, watch_data: WatchData, selected_audio: WatchMediaDomandAudio + ) -> str: + """Get HLS URL for selected audio.""" + # Create outputs list with selected audio ID only (audio-only) + outputs = [selected_audio.id_] + + hls_url = await self.service_manager._call_with_throttler( + self.niconico_py_client.video.watch.get_hls_content_url, + watch_data, + [outputs], # list[list[str]] format + ) + if not hls_url: + raise UnplayableMediaError("Failed to get HLS content URL") + + return hls_url + + async def _fetch_media_playlist_text(self, hls_url: str, domand_bid: str) -> str: + """Fetch media playlist text from HLS stream. + + Args: + hls_url: URL to the HLS playlist (master or media) + domand_bid: Authentication cookie value + + Returns: + Media playlist text (not parsed) + """ + headers = { + "User-Agent": NICOVIDEO_USER_AGENT, + "Cookie": f"{DOMAND_BID_COOKIE_NAME}={domand_bid}", + } + session = self.service_manager.provider.mass.http_session + + # Fetch master playlist + async with session.get(hls_url, headers=headers) as response: + response.raise_for_status() + master_playlist_text = await response.text() + + # Check if this is already a media playlist (has #EXTINF) + if "#EXTINF:" in master_playlist_text: + return master_playlist_text + + # Extract media playlist URL from master playlist + media_playlist_url = self._extract_media_playlist_url(master_playlist_text, hls_url) + + # Fetch media playlist + async with session.get(media_playlist_url, headers=headers) as response: + response.raise_for_status() + return await response.text() + + def _extract_media_playlist_url(self, master_playlist: str, base_url: str) -> str: + """Extract media playlist URL from master playlist. + + Args: + master_playlist: Master playlist text + base_url: Base URL for resolving relative URLs + + Returns: + Absolute URL to media playlist + """ + lines = master_playlist.split("\n") + for i, line in enumerate(lines): + # Look for stream info line followed by URL + if line.startswith("#EXT-X-STREAM-INF:"): + if i + 1 < len(lines): + media_url = lines[i + 1].strip() + if media_url and not media_url.startswith("#"): + # Resolve relative URL if needed + return urljoin(base_url, media_url) + msg = f"No media playlist URL found in master playlist from {base_url}" + raise InvalidDataError(msg) diff --git a/pyproject.toml b/pyproject.toml index e5a785fe06..456f5134dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ test = [ "syrupy==5.0.0", "tomli==2.3.0", "ruff==0.13.2", + "niconico.py @ git+https://github.com/Shi-553/niconico.py@b2d781de11df4bf42d0d42037ac7f5abb6822fda", ] [project.scripts] @@ -69,7 +70,7 @@ mass = "music_assistant.__main__:main" [tool.codespell] # explicit is misspelled in the iTunes API -ignore-words-list = "provid,hass,followings,childs,explict,commitish," +ignore-words-list = "provid,hass,followings,childs,explict,additionals,commitish," skip = """*.js,*.svg,\ music_assistant/providers/itunes_podcasts/itunes_country_codes.json,\ """ diff --git a/requirements_all.txt b/requirements_all.txt index 6a0d173dfb..3027ec321f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -38,6 +38,7 @@ mashumaro==3.16 music-assistant-frontend==2.17.7 music-assistant-models==1.1.67 mutagen==1.47.0 +niconico.py @ git+https://github.com/Shi-553/niconico.py@b2d781de11df4bf42d0d42037ac7f5abb6822fda numpy==2.2.6 orjson==3.11.4 pillow==11.3.0 diff --git a/tests/providers/nicovideo/README.md b/tests/providers/nicovideo/README.md new file mode 100644 index 0000000000..4c90c69f4a --- /dev/null +++ b/tests/providers/nicovideo/README.md @@ -0,0 +1,48 @@ +# Niconico Provider Tests + +Test suite for the Niconico music provider in Music Assistant. + +## Fixtures + +Test fixtures are JSON snapshots of Niconico API responses used for testing converters and business logic. + +### Updating Fixtures + +Fixtures are generated using a dedicated tool repository: + +**[music-assistant-nicovideo-fixtures](https://github.com/Shi-553/music-assistant-nicovideo-fixtures)** + +To update fixtures: + +1. Clone the fixtures repository (if not already cloned) +2. Follow setup instructions in that repository +3. Generate new fixtures with your test account: `python scripts/main.py` +4. Copy generated fixtures `cp -r /path/to/music_assistant_nicovideo_fixtures/fixture_data tests/providers/nicovideo/` + +**Important:** Always use a dedicated test account, never your personal account! + +## Running Tests + +```bash +# Run all nicovideo provider tests +pytest tests/providers/nicovideo/ + +# Run specific test file +pytest tests/providers/nicovideo/test_converters.py + +# Run with coverage +pytest --cov=music_assistant.providers.nicovideo tests/providers/nicovideo/ +``` + +## Test Structure + +``` +tests/providers/nicovideo/ +├── fixture_data/ # Fixture data from generator repository +│ ├── fixtures/ # Static JSON fixtures (API responses) +│ ├── fixture_type_mappings.py # Auto-generated type mappings +│ └── shared_types.py # Custom fixture types +├── fixtures/ # Fixture loading utilities +├── __snapshots__/ # Generated snapshots for comparison +└── test_*.py # Test files +``` diff --git a/tests/providers/nicovideo/__init__.py b/tests/providers/nicovideo/__init__.py new file mode 100644 index 0000000000..e8fdfc7c91 --- /dev/null +++ b/tests/providers/nicovideo/__init__.py @@ -0,0 +1 @@ +"""Tests for nicovideo provider.""" diff --git a/tests/providers/nicovideo/__snapshots__/test_converters.ambr b/tests/providers/nicovideo/__snapshots__/test_converters.ambr new file mode 100644 index 0000000000..86001e1792 --- /dev/null +++ b/tests/providers/nicovideo/__snapshots__/test_converters.ambr @@ -0,0 +1,2496 @@ +# serializer version: 1 +# name: test_converter_with_fixture[albums/own_series.json] + dict({ + 'album_type': 'unknown', + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': '', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': '', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '527007', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストシリーズ68461151-527007', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '527007', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'sort_name': 'テストシリース68461151-527007', + 'translation_key': None, + 'uri': 'nicovideo://album/527007', + 'version': '', + 'year': None, + }) +# --- +# name: test_converter_with_fixture[albums/single_series_details.json] + dict({ + 'album': dict({ + 'album_type': 'unknown', + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '527007', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストシリーズ68461151-527007', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '527007', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'sort_name': 'テストシリース68461151-527007', + 'translation_key': None, + 'uri': 'nicovideo://album/527007', + 'version': '', + 'year': None, + }), + 'tracks': list([ + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }), + ]), + }) +# --- +# name: test_converter_with_fixture[albums/user_series.json] + dict({ + 'album_type': 'unknown', + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': '', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': '', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '527007', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストシリーズ68461151-527007', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '527007', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'sort_name': 'テストシリース68461151-527007', + 'translation_key': None, + 'uri': 'nicovideo://album/527007', + 'version': '', + 'year': None, + }) +# --- +# name: test_converter_with_fixture[artists/following_users.json] + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '4', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/4', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': '中の', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '4', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/4', + }), + ]), + 'sort_name': '中の', + 'translation_key': None, + 'uri': 'nicovideo://artist/4', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[artists/user_details.json] + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[history/user_history.json] + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[history/user_likes.json] + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[playlists/following_mylists.json] + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_editable': False, + 'is_playable': True, + 'item_id': '78597499', + 'media_type': 'playlist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストマイリスト68461151-78597499', + 'owner': '68461151', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '78597499', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'sort_name': 'テストマイリスト68461151-78597499', + 'translation_key': None, + 'uri': 'nicovideo://playlist/78597499', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[playlists/own_mylists.json] + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_editable': True, + 'is_playable': True, + 'item_id': '78597499', + 'media_type': 'playlist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストマイリスト68461151-78597499', + 'owner': '68461151', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '78597499', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'sort_name': 'テストマイリスト68461151-78597499', + 'translation_key': None, + 'uri': 'nicovideo://playlist/78597499', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[playlists/single_mylist_details.json] + dict({ + 'playlist': dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_editable': True, + 'is_playable': True, + 'item_id': '78597499', + 'media_type': 'playlist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストマイリスト68461151-78597499', + 'owner': '68461151', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '78597499', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'sort_name': 'テストマイリスト68461151-78597499', + 'translation_key': None, + 'uri': 'nicovideo://playlist/78597499', + 'version': '', + }), + 'tracks': list([ + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }), + ]), + }) +# --- +# name: test_converter_with_fixture[search/mylist_search.json] + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_editable': True, + 'is_playable': True, + 'item_id': '78597499', + 'media_type': 'playlist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストマイリスト68461151-78597499', + 'owner': '68461151', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '78597499', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/mylist/78597499', + }), + ]), + 'sort_name': 'テストマイリスト68461151-78597499', + 'translation_key': None, + 'uri': 'nicovideo://playlist/78597499', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[search/series_search.json] + dict({ + 'album_type': 'unknown', + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '527007', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストシリーズ68461151-527007', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '527007', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'sort_name': 'テストシリース68461151-527007', + 'translation_key': None, + 'uri': 'nicovideo://album/527007', + 'version': '', + 'year': None, + }) +# --- +# name: test_converter_with_fixture[search/video_search_keyword.json] + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[search/video_search_tags.json] + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[stream/stream_data.json] + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 236125, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 48000, + }), + 'dsp': None, + 'duration': 2, + 'item_id': 'sm45285955', + 'loudness': -7000.0, + 'loudness_album': None, + 'media_type': 'track', + 'prefer_album_loudness': False, + 'provider': 'nicovideo_test', + 'size': None, + 'stream_metadata': dict({ + 'album': 'テストシリーズ68461151-527007', + 'artist': 'ゲスト', + 'description': None, + 'duration': None, + 'image_url': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006', + 'title': 'APIテスト用', + 'uri': None, + }), + 'stream_title': 'ゲスト - APIテスト用', + 'stream_type': 'custom', + 'target_loudness': None, + 'volume_normalization_gain_correct': None, + 'volume_normalization_mode': None, + }) +# --- +# name: test_converter_with_fixture[tracks/own_videos.json] + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[tracks/user_videos.json] + dict({ + 'album': None, + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': False, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- +# name: test_converter_with_fixture[tracks/watch_data.json] + dict({ + 'album': dict({ + 'album_type': 'unknown', + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '527007', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'テストシリーズ68461151-527007', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '527007', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/series/527007', + }), + ]), + 'sort_name': 'テストシリース68461151-527007', + 'translation_key': None, + 'uri': 'nicovideo://album/527007', + 'version': '', + 'year': None, + }), + 'artists': list([ + dict({ + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '68461151', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'ゲスト', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': '68461151', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/user/68461151', + }), + ]), + 'sort_name': 'ケスト', + 'translation_key': None, + 'uri': 'nicovideo://artist/68461151', + 'version': '', + }), + ]), + 'disc_number': 0, + 'duration': 2, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': 'sm45285955', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'This is a dummy description for testing purposes.', + 'explicit': None, + 'genres': list([ + 'APIテストタグ68461151-45285955', + 'テスト', + 'テスト動画', + ]), + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + dict({ + 'path': 'https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M', + 'provider': 'nicovideo', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': list([ + dict({ + 'type': 'website', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': 0, + 'preview': None, + 'release_date': '2025-01-01T00:00:00+09:00', + 'review': None, + 'style': None, + }), + 'name': 'APIテスト用', + 'position': None, + 'provider': 'nicovideo', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 236125, + 'channels': 2, + 'codec_type': 'aac', + 'content_type': 'mp4', + 'output_format_str': 'mp4', + 'sample_rate': 48000, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'item_id': 'sm45285955', + 'provider_domain': 'nicovideo', + 'provider_instance': 'nicovideo_test', + 'url': 'https://www.nicovideo.jp/watch/sm45285955', + }), + ]), + 'sort_name': 'apiテスト用', + 'track_number': 0, + 'translation_key': None, + 'uri': 'nicovideo://track/sm45285955', + 'version': '', + }) +# --- diff --git a/tests/providers/nicovideo/conftest.py b/tests/providers/nicovideo/conftest.py new file mode 100644 index 0000000000..ebd0b1e6de --- /dev/null +++ b/tests/providers/nicovideo/conftest.py @@ -0,0 +1,31 @@ +"""Common fixtures and configuration for nicovideo tests.""" + +from __future__ import annotations + +import pytest + +from music_assistant.providers.nicovideo.converters.manager import NicovideoConverterManager +from tests.providers.nicovideo.constants import GENERATED_FIXTURES_DIR +from tests.providers.nicovideo.fixtures.api_response_converter_mapping import ( + APIResponseConverterMappingRegistry, +) +from tests.providers.nicovideo.fixtures.fixture_loader import FixtureLoader +from tests.providers.nicovideo.helpers import create_converter_manager + + +@pytest.fixture +def fixture_loader() -> FixtureLoader: + """Provide a FixtureLoader instance.""" + return FixtureLoader(GENERATED_FIXTURES_DIR) + + +@pytest.fixture +def converter_manager() -> NicovideoConverterManager: + """Provide a NicovideoConverterManager instance.""" + return create_converter_manager() + + +@pytest.fixture +def mapping_registry() -> APIResponseConverterMappingRegistry: + """Provide an APIResponseConverterMappingRegistry.""" + return APIResponseConverterMappingRegistry() diff --git a/tests/providers/nicovideo/constants.py b/tests/providers/nicovideo/constants.py new file mode 100644 index 0000000000..fcc165ceec --- /dev/null +++ b/tests/providers/nicovideo/constants.py @@ -0,0 +1,10 @@ +"""Common constants for nicovideo tests.""" + +from __future__ import annotations + +import pathlib + +# Test fixtures directories +_BASE_DIR = pathlib.Path(__file__).parent +FIXTURE_DATA_DIR = _BASE_DIR / "fixture_data" +GENERATED_FIXTURES_DIR = FIXTURE_DATA_DIR / "fixtures" diff --git a/tests/providers/nicovideo/fixture_data/__init__.py b/tests/providers/nicovideo/fixture_data/__init__.py new file mode 100644 index 0000000000..4b05d29ca6 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/__init__.py @@ -0,0 +1 @@ +"""Fixture data package for nicovideo provider tests.""" diff --git a/tests/providers/nicovideo/fixture_data/fixture_type_mappings.py b/tests/providers/nicovideo/fixture_data/fixture_type_mappings.py new file mode 100644 index 0000000000..83ce668b67 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixture_type_mappings.py @@ -0,0 +1,47 @@ +"""Fixture type mappings for automatic deserialization.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from niconico.objects.nvapi import ( + FollowingMylistsData, + HistoryData, + LikeHistoryData, + ListSearchData, + OwnVideosData, + RelationshipUsersData, + SeriesData, + UserVideosData, + VideoSearchData, +) +from niconico.objects.user import NicoUser, UserMylistItem, UserSeriesItem +from niconico.objects.video import Mylist +from niconico.objects.video.watch import WatchData + +from .shared_types import StreamFixtureData + +if TYPE_CHECKING: + from pydantic import BaseModel + +# Fixture type mappings: path -> type +FIXTURE_TYPE_MAPPINGS: dict[str, type[BaseModel]] = { + "tracks/own_videos.json": OwnVideosData, + "tracks/watch_data.json": WatchData, + "tracks/user_videos.json": UserVideosData, + "playlists/own_mylists.json": UserMylistItem, + "playlists/following_mylists.json": FollowingMylistsData, + "playlists/single_mylist_details.json": Mylist, + "albums/own_series.json": UserSeriesItem, + "albums/user_series.json": UserSeriesItem, + "albums/single_series_details.json": SeriesData, + "artists/following_users.json": RelationshipUsersData, + "artists/user_details.json": NicoUser, + "search/video_search_keyword.json": VideoSearchData, + "search/video_search_tags.json": VideoSearchData, + "search/mylist_search.json": ListSearchData, + "search/series_search.json": ListSearchData, + "history/user_history.json": HistoryData, + "history/user_likes.json": LikeHistoryData, + "stream/stream_data.json": StreamFixtureData, +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/albums/own_series.json b/tests/providers/nicovideo/fixture_data/fixtures/albums/own_series.json new file mode 100644 index 0000000000..e2b4db3953 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/albums/own_series.json @@ -0,0 +1,14 @@ +[ + { + "id": 527007, + "owner": { + "type": "user", + "id": "68461151" + }, + "title": "テストシリーズ68461151-527007", + "isListed": true, + "description": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "itemsCount": 1 + } +] diff --git a/tests/providers/nicovideo/fixture_data/fixtures/albums/single_series_details.json b/tests/providers/nicovideo/fixture_data/fixtures/albums/single_series_details.json new file mode 100644 index 0000000000..ac3f6b4107 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/albums/single_series_details.json @@ -0,0 +1,77 @@ +{ + "detail": { + "id": 527007, + "owner": { + "type": "user", + "id": "68461151", + "user": { + "type": "essential", + "isPremium": false, + "description": "This is a dummy description for testing purposes.", + "strippedDescription": "This is a dummy description for testing purposes.", + "shortDescription": "This is a dummy description for testing purposes.", + "id": 68461151, + "nickname": "ゲスト", + "icons": { + "small": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank_s.jpg", + "large": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + } + }, + "channel": null + }, + "title": "テストシリーズ68461151-527007", + "description": "This is a dummy description for testing purposes.", + "decoratedDescriptionHtml": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "isListed": true, + "createdAt": "2025-08-10T17:05:05+09:00", + "updatedAt": "2025-08-13T18:53:28+09:00" + }, + "totalCount": 1, + "items": [ + { + "meta": { + "id": "sm45285955", + "order": 2, + "createdAt": "2025-08-13T17:37:03+09:00", + "updatedAt": "2025-08-13T17:37:03+09:00" + }, + "video": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + } + ] +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/albums/user_series.json b/tests/providers/nicovideo/fixture_data/fixtures/albums/user_series.json new file mode 100644 index 0000000000..e2b4db3953 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/albums/user_series.json @@ -0,0 +1,14 @@ +[ + { + "id": 527007, + "owner": { + "type": "user", + "id": "68461151" + }, + "title": "テストシリーズ68461151-527007", + "isListed": true, + "description": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "itemsCount": 1 + } +] diff --git a/tests/providers/nicovideo/fixture_data/fixtures/artists/following_users.json b/tests/providers/nicovideo/fixture_data/fixtures/artists/following_users.json new file mode 100644 index 0000000000..cddc40c6f3 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/artists/following_users.json @@ -0,0 +1,29 @@ +{ + "items": [ + { + "type": "relationship", + "relationships": { + "sessionUser": { + "isFollowing": true + }, + "isMe": false + }, + "isPremium": false, + "description": "This is a dummy description for testing purposes.", + "strippedDescription": "This is a dummy description for testing purposes.", + "shortDescription": "This is a dummy description for testing purposes.", + "id": 4, + "nickname": "中の", + "icons": { + "small": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank_s.jpg", + "large": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + } + } + ], + "summary": { + "followees": 1, + "followers": 0, + "hasNext": false, + "cursor": "cursorEnd" + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/artists/user_details.json b/tests/providers/nicovideo/fixture_data/fixtures/artists/user_details.json new file mode 100644 index 0000000000..6a576cd1fa --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/artists/user_details.json @@ -0,0 +1,25 @@ +{ + "description": "This is a dummy description for testing purposes.", + "decoratedDescriptionHtml": "This is a dummy description for testing purposes.", + "strippedDescription": "This is a dummy description for testing purposes.", + "isPremium": false, + "registeredVersion": "(GINZA)", + "followeeCount": 1, + "followerCount": 1, + "userLevel": { + "currentLevel": 1, + "nextLevelThresholdExperience": 100, + "nextLevelExperience": 100, + "currentLevelExperience": 0 + }, + "userChannel": null, + "isNicorepoReadable": false, + "sns": [], + "coverImage": null, + "id": 68461151, + "nickname": "ゲスト", + "icons": { + "small": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank_s.jpg", + "large": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/history/user_history.json b/tests/providers/nicovideo/fixture_data/fixtures/history/user_history.json new file mode 100644 index 0000000000..42b0cc6720 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/history/user_history.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "frontendId": 6, + "isMaybeLikeUserItem": false, + "lastViewedAt": "2025-01-01T00:00:00+09:00", + "playbackPosition": 0.0, + "video": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + }, + "views": 1, + "watchId": "sm45285955" + } + ], + "totalCount": 1 +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/history/user_likes.json b/tests/providers/nicovideo/fixture_data/fixtures/history/user_likes.json new file mode 100644 index 0000000000..eb7b68ad61 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/history/user_likes.json @@ -0,0 +1,50 @@ +{ + "items": [ + { + "likedAt": "2025-08-13T17:44:46+09:00", + "thanksMessage": "お礼テスト", + "video": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + }, + "status": "public" + } + ], + "summary": { + "hasNext": true, + "canGetNextPage": true, + "getNextPageNgReason": null + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/playlists/following_mylists.json b/tests/providers/nicovideo/fixture_data/fixtures/playlists/following_mylists.json new file mode 100644 index 0000000000..ecf78eb988 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/playlists/following_mylists.json @@ -0,0 +1,31 @@ +{ + "followLimit": 20, + "mylists": [ + { + "id": 78597499, + "status": "public", + "detail": { + "id": 78597499, + "isPublic": true, + "name": "テストマイリスト68461151-78597499", + "description": "This is a dummy description for testing purposes.", + "decoratedDescriptionHtml": "This is a dummy description for testing purposes.", + "defaultSortKey": "addedAt", + "defaultSortOrder": "desc", + "itemsCount": 1, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "sampleItems": [], + "followerCount": 1, + "createdAt": "2025-08-10T16:58:04+09:00", + "isFollowing": true + } + } + ] +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/playlists/own_mylists.json b/tests/providers/nicovideo/fixture_data/fixtures/playlists/own_mylists.json new file mode 100644 index 0000000000..521cacec47 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/playlists/own_mylists.json @@ -0,0 +1,24 @@ +[ + { + "id": 78597499, + "isPublic": true, + "name": "テストマイリスト68461151-78597499", + "description": "This is a dummy description for testing purposes.", + "decoratedDescriptionHtml": "This is a dummy description for testing purposes.", + "defaultSortKey": "addedAt", + "defaultSortOrder": "desc", + "itemsCount": 1, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "sampleItems": [], + "followerCount": 1, + "createdAt": "2025-08-10T16:58:04+09:00", + "isFollowing": true + } +] diff --git a/tests/providers/nicovideo/fixture_data/fixtures/playlists/single_mylist_details.json b/tests/providers/nicovideo/fixture_data/fixtures/playlists/single_mylist_details.json new file mode 100644 index 0000000000..34074ae29b --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/playlists/single_mylist_details.json @@ -0,0 +1,68 @@ +{ + "id": 78597499, + "name": "テストマイリスト68461151-78597499", + "description": "This is a dummy description for testing purposes.", + "decoratedDescriptionHtml": "This is a dummy description for testing purposes.", + "defaultSortKey": "addedAt", + "defaultSortOrder": "desc", + "items": [ + { + "itemId": 1755074224, + "watchId": "sm45285955", + "description": "This is a dummy description for testing purposes.", + "decoratedDescriptionHtml": "This is a dummy description for testing purposes.", + "addedAt": "2025-08-13T17:37:25+09:00", + "status": "public", + "video": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + } + ], + "totalItemCount": 1, + "hasNext": true, + "isPublic": true, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "hasInvisibleItems": false, + "followerCount": 1, + "isFollowing": true +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/search/mylist_search.json b/tests/providers/nicovideo/fixture_data/fixtures/search/mylist_search.json new file mode 100644 index 0000000000..a6b5e9a3e8 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/search/mylist_search.json @@ -0,0 +1,26 @@ +{ + "searchId": "dummy-search-id-for-testing", + "totalCount": 1, + "hasNext": false, + "items": [ + { + "id": 78597499, + "type": "mylist", + "title": "テストマイリスト68461151-78597499", + "description": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "videoCount": 1, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "isMuted": false, + "isFollowing": true, + "followerCount": 1 + } + ] +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/search/series_search.json b/tests/providers/nicovideo/fixture_data/fixtures/search/series_search.json new file mode 100644 index 0000000000..1aa3046fc9 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/search/series_search.json @@ -0,0 +1,26 @@ +{ + "searchId": "dummy-search-id-for-testing", + "totalCount": 1, + "hasNext": false, + "items": [ + { + "id": 527007, + "type": "series", + "title": "テストシリーズ68461151-527007", + "description": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "videoCount": 1, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "isMuted": false, + "isFollowing": false, + "followerCount": 1 + } + ] +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/search/video_search_keyword.json b/tests/providers/nicovideo/fixture_data/fixtures/search/video_search_keyword.json new file mode 100644 index 0000000000..e000866ce4 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/search/video_search_keyword.json @@ -0,0 +1,49 @@ +{ + "searchId": "dummy-search-id-for-testing", + "keyword": "APIテスト68461151-45285955", + "tag": null, + "genres": [], + "totalCount": 1, + "hasNext": false, + "items": [ + { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + ], + "additionals": { + "tags": [] + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/search/video_search_tags.json b/tests/providers/nicovideo/fixture_data/fixtures/search/video_search_tags.json new file mode 100644 index 0000000000..6d0bc2aeb2 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/search/video_search_tags.json @@ -0,0 +1,49 @@ +{ + "searchId": "dummy-search-id-for-testing", + "keyword": null, + "tag": "APIテストタグ68461151-45285955", + "genres": [], + "totalCount": 1, + "hasNext": false, + "items": [ + { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + ], + "additionals": { + "tags": [] + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/stream/stream_data.json b/tests/providers/nicovideo/fixture_data/fixtures/stream/stream_data.json new file mode 100644 index 0000000000..b6513c6700 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/stream/stream_data.json @@ -0,0 +1,657 @@ +{ + "watch_data": { + "ads": null, + "category": null, + "channel": null, + "client": { + "nicosid": "dummy_nicosid_for_testing", + "watchId": "sm45285955", + "watchTrackId": "dummy_track_id_for_testing" + }, + "comment": { + "layers": [ + { + "index": 0, + "isTranslucent": false, + "threadIds": [ + { + "id": 1755074224, + "fork": 1, + "forkLabel": "owner" + } + ] + }, + { + "index": 1, + "isTranslucent": false, + "threadIds": [ + { + "id": 1755074224, + "fork": 0, + "forkLabel": "main" + }, + { + "id": 1755074224, + "fork": 2, + "forkLabel": "easy" + } + ] + } + ], + "threads": [ + { + "id": 1755074224, + "fork": 1, + "forkLabel": "owner", + "videoId": "sm45285955", + "isActive": false, + "isDefaultPostTarget": false, + "isEasyCommentPostTarget": false, + "isLeafRequired": false, + "isOwnerThread": true, + "isThreadkeyRequired": false, + "threadkey": null, + "is184Forced": false, + "hasNicoscript": true, + "label": "owner", + "postkeyStatus": 0, + "server": "" + }, + { + "id": 1755074224, + "fork": 0, + "forkLabel": "main", + "videoId": "sm45285955", + "isActive": true, + "isDefaultPostTarget": true, + "isEasyCommentPostTarget": false, + "isLeafRequired": true, + "isOwnerThread": false, + "isThreadkeyRequired": false, + "threadkey": null, + "is184Forced": false, + "hasNicoscript": false, + "label": "default", + "postkeyStatus": 0, + "server": "" + }, + { + "id": 1755074224, + "fork": 2, + "forkLabel": "easy", + "videoId": "sm45285955", + "isActive": true, + "isDefaultPostTarget": false, + "isEasyCommentPostTarget": true, + "isLeafRequired": true, + "isOwnerThread": false, + "isThreadkeyRequired": false, + "threadkey": null, + "is184Forced": false, + "hasNicoscript": false, + "label": "easy", + "postkeyStatus": 0, + "server": "" + } + ], + "ng": { + "ngScore": { + "isDisabled": false + }, + "channel": [], + "owner": [], + "viewer": { + "revision": 1, + "count": 1, + "items": [] + } + }, + "isAttentionRequired": false, + "nvComment": { + "threadKey": "dummy.jwt.token.for.testing", + "server": "https://public.nvcomment.nicovideo.jp", + "params": { + "targets": [ + { + "id": "1755074224", + "fork": "owner" + }, + { + "id": "1755074224", + "fork": "main" + }, + { + "id": "1755074224", + "fork": "easy" + } + ], + "language": "ja-jp" + } + } + }, + "community": null, + "easyComment": { + "phrases": [] + }, + "external": { + "commons": { + "hasContentTree": true + }, + "ichiba": { + "isEnabled": true + } + }, + "genre": { + "key": "other", + "label": "その他", + "isImmoral": false, + "isDisabled": false, + "isNotSet": false + }, + "marquee": { + "isDisabled": false, + "tagRelatedLead": null + }, + "media": { + "domand": { + "videos": [ + { + "id": "video-h264-1080p", + "isAvailable": true, + "label": "1080p", + "bitRate": 25878, + "width": 1466, + "height": 1080, + "qualityLevel": 4, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-720p", + "isAvailable": true, + "label": "720p", + "bitRate": 19535, + "width": 978, + "height": 720, + "qualityLevel": 3, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-480p", + "isAvailable": true, + "label": "480p", + "bitRate": 16906, + "width": 652, + "height": 480, + "qualityLevel": 2, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-360p", + "isAvailable": true, + "label": "360p", + "bitRate": 16054, + "width": 488, + "height": 360, + "qualityLevel": 1, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-144p", + "isAvailable": true, + "label": "144p", + "bitRate": 14876, + "width": 196, + "height": 144, + "qualityLevel": 0, + "recommendedHighestAudioQualityLevel": 1 + } + ], + "audios": [ + { + "id": "audio-aac-192kbps", + "isAvailable": true, + "bitRate": 236125, + "samplingRate": 48000, + "integratedLoudness": -7000.0, + "truePeak": -7000.0, + "qualityLevel": 1, + "loudnessCollection": [ + { + "type": "video", + "value": 1.0 + }, + { + "type": "pureAdPreroll", + "value": 0.1 + }, + { + "type": "houseAdPreroll", + "value": 0.1 + }, + { + "type": "networkAdPreroll", + "value": 0.1 + }, + { + "type": "pureAdMidroll", + "value": 0.1 + }, + { + "type": "houseAdMidroll", + "value": 0.1 + }, + { + "type": "networkAdMidroll", + "value": 0.1 + }, + { + "type": "pureAdPostroll", + "value": 0.1 + }, + { + "type": "houseAdPostroll", + "value": 0.1 + }, + { + "type": "networkAdPostroll", + "value": 0.1 + }, + { + "type": "nicoadVideoIntroduce", + "value": 0.1 + }, + { + "type": "nicoadBillboard", + "value": 0.1 + }, + { + "type": "marquee", + "value": 0.1 + } + ] + }, + { + "id": "audio-aac-64kbps", + "isAvailable": true, + "bitRate": 72347, + "samplingRate": 48000, + "integratedLoudness": -7000.0, + "truePeak": -7000.0, + "qualityLevel": 0, + "loudnessCollection": [ + { + "type": "video", + "value": 1.0 + }, + { + "type": "pureAdPreroll", + "value": 0.1 + }, + { + "type": "houseAdPreroll", + "value": 0.1 + }, + { + "type": "networkAdPreroll", + "value": 0.1 + }, + { + "type": "pureAdMidroll", + "value": 0.1 + }, + { + "type": "houseAdMidroll", + "value": 0.1 + }, + { + "type": "networkAdMidroll", + "value": 0.1 + }, + { + "type": "pureAdPostroll", + "value": 0.1 + }, + { + "type": "houseAdPostroll", + "value": 0.1 + }, + { + "type": "networkAdPostroll", + "value": 0.1 + }, + { + "type": "nicoadVideoIntroduce", + "value": 0.1 + }, + { + "type": "nicoadBillboard", + "value": 0.1 + }, + { + "type": "marquee", + "value": 0.1 + } + ] + } + ], + "isStoryboardAvailable": true, + "accessRightKey": "dummy.jwt.token.for.testing" + }, + "delivery": null, + "deliveryLegacy": null + }, + "okReason": "PURELY", + "owner": { + "id": 68461151, + "nickname": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg", + "channel": null, + "live": null, + "isVideosPublic": true, + "isMylistsPublic": true, + "videoLiveNotice": null, + "viewer": { + "isFollowing": false + } + }, + "payment": { + "video": { + "isPpv": false, + "isAdmission": false, + "isContinuationBenefit": false, + "isPremium": false, + "watchableUserType": "all", + "commentableUserType": "all", + "billingType": "free" + }, + "preview": { + "ppv": { + "isEnabled": false + }, + "admission": { + "isEnabled": false + }, + "continuationBenefit": { + "isEnabled": false + }, + "premium": { + "isEnabled": false + } + } + }, + "pcWatchPage": { + "tagRelatedBanner": null, + "videoEnd": { + "bannerIn": null, + "overlay": null + }, + "showOwnerMenu": true, + "showOwnerThreadCoEditingLink": true, + "showMymemoryEditingLink": false, + "channelGtmContainerId": "GTM-K8M6VGZ" + }, + "player": { + "initialPlayback": null, + "comment": { + "isDefaultInvisible": false + }, + "layerMode": 0 + }, + "ppv": null, + "ranking": { + "genre": null, + "popularTag": [] + }, + "series": { + "id": 527007, + "title": "テストシリーズ68461151-527007", + "description": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "video": { + "prev": null, + "next": null, + "first": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + } + }, + "smartphone": null, + "system": { + "serverTime": "2025-01-01T00:00:00+09:00", + "isPeakTime": false, + "isStellaAlive": true + }, + "tag": { + "items": [ + { + "name": "テスト", + "isCategory": false, + "isCategoryCandidate": false, + "isNicodicArticleExists": true, + "isLocked": true + }, + { + "name": "テスト動画", + "isCategory": false, + "isCategoryCandidate": false, + "isNicodicArticleExists": true, + "isLocked": true + }, + { + "name": "APIテストタグ68461151-45285955", + "isCategory": false, + "isCategoryCandidate": false, + "isNicodicArticleExists": false, + "isLocked": true + } + ], + "hasR18Tag": false, + "isPublishedNicoscript": false, + "edit": { + "isEditable": true, + "uneditableReason": null, + "editKey": "dummy.jwt.token.for.testing" + }, + "viewer": { + "isEditable": true, + "uneditableReason": null, + "editKey": "dummy.jwt.token.for.testing" + } + }, + "video": { + "id": "sm45285955", + "title": "APIテスト用", + "description": "This is a dummy description for testing purposes.", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "duration": 2, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "player": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/a960x540l?key=4a2d8a3899b06080a6a9c385fc4d27a709b6c7a48504c4044f68c0ad78e4b905", + "ogp": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r1280x720l?key=50d3132952586005b060e4d9a1e81bc69097acc4db1119198a962b062c02e3b3" + }, + "rating": { + "isAdult": false + }, + "registeredAt": "2025-01-01T00:00:00+09:00", + "isPrivate": false, + "isDeleted": false, + "isNoBanner": false, + "isAuthenticationRequired": false, + "isEmbedPlayerAllowed": true, + "isGiftAllowed": true, + "viewer": { + "isOwner": true, + "like": { + "isLiked": true, + "count": null + } + }, + "watchableUserTypeForPayment": "all", + "commentableUserTypeForPayment": "all" + }, + "videoAds": { + "additionalParams": { + "videoId": "sm45285955", + "videoDuration": 2, + "isAdultRatingNG": false, + "isAuthenticationRequired": false, + "isR18": false, + "nicosid": "dummy_nicosid_for_testing", + "lang": "ja-jp", + "watchTrackId": "dummy_track_id_for_testing", + "genre": "other", + "gender": "4", + "age": 65 + }, + "items": [ + { + "type": "preroll", + "timingMs": null, + "additionalParams": { + "linearType": "preroll", + "adIdx": 0, + "skipType": 1, + "skippableType": 1, + "pod": 1 + } + }, + { + "type": "postroll", + "timingMs": null, + "additionalParams": { + "linearType": "postroll", + "adIdx": 0, + "skipType": 1, + "skippableType": 1, + "pod": 2 + } + } + ], + "reason": "non_premium_user_ads" + }, + "videoLive": null, + "viewer": { + "id": 68461151, + "nickname": "ゲスト", + "isPremium": false, + "allowSensitiveContents": true, + "existence": { + "age": 65, + "prefecture": "北海道", + "sex": "unanswered" + } + }, + "waku": { + "information": null, + "bgImages": [], + "addContents": null, + "addVideo": null, + "tagRelatedBanner": null, + "tagRelatedMarquee": null, + "pcWatchHeaderCustomBanner": null + } + }, + "selected_audio": { + "id": "audio-aac-192kbps", + "isAvailable": true, + "bitRate": 236125, + "samplingRate": 48000, + "integratedLoudness": -7000.0, + "truePeak": -7000.0, + "qualityLevel": 1, + "loudnessCollection": [ + { + "type": "video", + "value": 1.0 + }, + { + "type": "pureAdPreroll", + "value": 0.1 + }, + { + "type": "houseAdPreroll", + "value": 0.1 + }, + { + "type": "networkAdPreroll", + "value": 0.1 + }, + { + "type": "pureAdMidroll", + "value": 0.1 + }, + { + "type": "houseAdMidroll", + "value": 0.1 + }, + { + "type": "networkAdMidroll", + "value": 0.1 + }, + { + "type": "pureAdPostroll", + "value": 0.1 + }, + { + "type": "houseAdPostroll", + "value": 0.1 + }, + { + "type": "networkAdPostroll", + "value": 0.1 + }, + { + "type": "nicoadVideoIntroduce", + "value": 0.1 + }, + { + "type": "nicoadBillboard", + "value": 0.1 + }, + { + "type": "marquee", + "value": 0.1 + } + ] + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/tracks/own_videos.json b/tests/providers/nicovideo/fixture_data/fixtures/tracks/own_videos.json new file mode 100644 index 0000000000..bf601931a4 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/tracks/own_videos.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "isCaptureTweetAllowed": true, + "isClipTweetAllowed": true, + "isCommunityMemberOnly": false, + "description": "This is a dummy description for testing purposes.", + "isHidden": false, + "isDeleted": false, + "isCppRegistered": false, + "isContentsTreeExists": false, + "publishTimerDetail": null, + "autoDeleteDetail": null, + "isExcludeFromUploadList": false, + "likeCount": 1, + "giftPoint": 0, + "essential": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + }, + "series": { + "id": 527007, + "title": "テストシリーズ68461151-527007", + "order": 2 + } + } + ], + "totalCount": 1, + "totalItemCount": 1, + "limitation": { + "borderId": 30186930, + "user": { + "uploadableCount": 1, + "uploadedCountForLimitation": 1 + } + } +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/tracks/user_videos.json b/tests/providers/nicovideo/fixture_data/fixtures/tracks/user_videos.json new file mode 100644 index 0000000000..d0bcd1b119 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/tracks/user_videos.json @@ -0,0 +1,48 @@ +{ + "items": [ + { + "series": { + "id": 527007, + "title": "テストシリーズ68461151-527007", + "order": 2 + }, + "essential": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + } + ], + "totalCount": 1 +} diff --git a/tests/providers/nicovideo/fixture_data/fixtures/tracks/watch_data.json b/tests/providers/nicovideo/fixture_data/fixtures/tracks/watch_data.json new file mode 100644 index 0000000000..13df6d5752 --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/fixtures/tracks/watch_data.json @@ -0,0 +1,592 @@ +{ + "ads": null, + "category": null, + "channel": null, + "client": { + "nicosid": "dummy_nicosid_for_testing", + "watchId": "sm45285955", + "watchTrackId": "dummy_track_id_for_testing" + }, + "comment": { + "layers": [ + { + "index": 0, + "isTranslucent": false, + "threadIds": [ + { + "id": 1755074224, + "fork": 1, + "forkLabel": "owner" + } + ] + }, + { + "index": 1, + "isTranslucent": false, + "threadIds": [ + { + "id": 1755074224, + "fork": 0, + "forkLabel": "main" + }, + { + "id": 1755074224, + "fork": 2, + "forkLabel": "easy" + } + ] + } + ], + "threads": [ + { + "id": 1755074224, + "fork": 1, + "forkLabel": "owner", + "videoId": "sm45285955", + "isActive": false, + "isDefaultPostTarget": false, + "isEasyCommentPostTarget": false, + "isLeafRequired": false, + "isOwnerThread": true, + "isThreadkeyRequired": false, + "threadkey": null, + "is184Forced": false, + "hasNicoscript": true, + "label": "owner", + "postkeyStatus": 0, + "server": "" + }, + { + "id": 1755074224, + "fork": 0, + "forkLabel": "main", + "videoId": "sm45285955", + "isActive": true, + "isDefaultPostTarget": true, + "isEasyCommentPostTarget": false, + "isLeafRequired": true, + "isOwnerThread": false, + "isThreadkeyRequired": false, + "threadkey": null, + "is184Forced": false, + "hasNicoscript": false, + "label": "default", + "postkeyStatus": 0, + "server": "" + }, + { + "id": 1755074224, + "fork": 2, + "forkLabel": "easy", + "videoId": "sm45285955", + "isActive": true, + "isDefaultPostTarget": false, + "isEasyCommentPostTarget": true, + "isLeafRequired": true, + "isOwnerThread": false, + "isThreadkeyRequired": false, + "threadkey": null, + "is184Forced": false, + "hasNicoscript": false, + "label": "easy", + "postkeyStatus": 0, + "server": "" + } + ], + "ng": { + "ngScore": { + "isDisabled": false + }, + "channel": [], + "owner": [], + "viewer": { + "revision": 1, + "count": 1, + "items": [] + } + }, + "isAttentionRequired": false, + "nvComment": { + "threadKey": "dummy.jwt.token.for.testing", + "server": "https://public.nvcomment.nicovideo.jp", + "params": { + "targets": [ + { + "id": "1755074224", + "fork": "owner" + }, + { + "id": "1755074224", + "fork": "main" + }, + { + "id": "1755074224", + "fork": "easy" + } + ], + "language": "ja-jp" + } + } + }, + "community": null, + "easyComment": { + "phrases": [] + }, + "external": { + "commons": { + "hasContentTree": true + }, + "ichiba": { + "isEnabled": true + } + }, + "genre": { + "key": "other", + "label": "その他", + "isImmoral": false, + "isDisabled": false, + "isNotSet": false + }, + "marquee": { + "isDisabled": false, + "tagRelatedLead": null + }, + "media": { + "domand": { + "videos": [ + { + "id": "video-h264-1080p", + "isAvailable": true, + "label": "1080p", + "bitRate": 25878, + "width": 1466, + "height": 1080, + "qualityLevel": 4, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-720p", + "isAvailable": true, + "label": "720p", + "bitRate": 19535, + "width": 978, + "height": 720, + "qualityLevel": 3, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-480p", + "isAvailable": true, + "label": "480p", + "bitRate": 16906, + "width": 652, + "height": 480, + "qualityLevel": 2, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-360p", + "isAvailable": true, + "label": "360p", + "bitRate": 16054, + "width": 488, + "height": 360, + "qualityLevel": 1, + "recommendedHighestAudioQualityLevel": 1 + }, + { + "id": "video-h264-144p", + "isAvailable": true, + "label": "144p", + "bitRate": 14876, + "width": 196, + "height": 144, + "qualityLevel": 0, + "recommendedHighestAudioQualityLevel": 1 + } + ], + "audios": [ + { + "id": "audio-aac-192kbps", + "isAvailable": true, + "bitRate": 236125, + "samplingRate": 48000, + "integratedLoudness": -7000.0, + "truePeak": -7000.0, + "qualityLevel": 1, + "loudnessCollection": [ + { + "type": "video", + "value": 1.0 + }, + { + "type": "pureAdPreroll", + "value": 0.1 + }, + { + "type": "houseAdPreroll", + "value": 0.1 + }, + { + "type": "networkAdPreroll", + "value": 0.1 + }, + { + "type": "pureAdMidroll", + "value": 0.1 + }, + { + "type": "houseAdMidroll", + "value": 0.1 + }, + { + "type": "networkAdMidroll", + "value": 0.1 + }, + { + "type": "pureAdPostroll", + "value": 0.1 + }, + { + "type": "houseAdPostroll", + "value": 0.1 + }, + { + "type": "networkAdPostroll", + "value": 0.1 + }, + { + "type": "nicoadVideoIntroduce", + "value": 0.1 + }, + { + "type": "nicoadBillboard", + "value": 0.1 + }, + { + "type": "marquee", + "value": 0.1 + } + ] + }, + { + "id": "audio-aac-64kbps", + "isAvailable": true, + "bitRate": 72347, + "samplingRate": 48000, + "integratedLoudness": -7000.0, + "truePeak": -7000.0, + "qualityLevel": 0, + "loudnessCollection": [ + { + "type": "video", + "value": 1.0 + }, + { + "type": "pureAdPreroll", + "value": 0.1 + }, + { + "type": "houseAdPreroll", + "value": 0.1 + }, + { + "type": "networkAdPreroll", + "value": 0.1 + }, + { + "type": "pureAdMidroll", + "value": 0.1 + }, + { + "type": "houseAdMidroll", + "value": 0.1 + }, + { + "type": "networkAdMidroll", + "value": 0.1 + }, + { + "type": "pureAdPostroll", + "value": 0.1 + }, + { + "type": "houseAdPostroll", + "value": 0.1 + }, + { + "type": "networkAdPostroll", + "value": 0.1 + }, + { + "type": "nicoadVideoIntroduce", + "value": 0.1 + }, + { + "type": "nicoadBillboard", + "value": 0.1 + }, + { + "type": "marquee", + "value": 0.1 + } + ] + } + ], + "isStoryboardAvailable": true, + "accessRightKey": "dummy.jwt.token.for.testing" + }, + "delivery": null, + "deliveryLegacy": null + }, + "okReason": "PURELY", + "owner": { + "id": 68461151, + "nickname": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg", + "channel": null, + "live": null, + "isVideosPublic": true, + "isMylistsPublic": true, + "videoLiveNotice": null, + "viewer": { + "isFollowing": false + } + }, + "payment": { + "video": { + "isPpv": false, + "isAdmission": false, + "isContinuationBenefit": false, + "isPremium": false, + "watchableUserType": "all", + "commentableUserType": "all", + "billingType": "free" + }, + "preview": { + "ppv": { + "isEnabled": false + }, + "admission": { + "isEnabled": false + }, + "continuationBenefit": { + "isEnabled": false + }, + "premium": { + "isEnabled": false + } + } + }, + "pcWatchPage": { + "tagRelatedBanner": null, + "videoEnd": { + "bannerIn": null, + "overlay": null + }, + "showOwnerMenu": true, + "showOwnerThreadCoEditingLink": true, + "showMymemoryEditingLink": false, + "channelGtmContainerId": "GTM-K8M6VGZ" + }, + "player": { + "initialPlayback": null, + "comment": { + "isDefaultInvisible": false + }, + "layerMode": 0 + }, + "ppv": null, + "ranking": { + "genre": null, + "popularTag": [] + }, + "series": { + "id": 527007, + "title": "テストシリーズ68461151-527007", + "description": "This is a dummy description for testing purposes.", + "thumbnailUrl": "https://resource.video.nimg.jp/web/img/series/no_thumbnail.png", + "video": { + "prev": null, + "next": null, + "first": { + "type": "essential", + "id": "sm45285955", + "title": "APIテスト用", + "registeredAt": "2025-01-01T00:00:00+09:00", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "listingUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "nHdUrl": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r640x360l?key=7098d92a9c12d0b14101a4c3c8297e04c6e7a59eb59ffe9e71c88fa0181b0cb5" + }, + "duration": 2, + "shortDescription": "This is a dummy description for testing purposes.", + "latestCommentSummary": "", + "isChannelVideo": false, + "isPaymentRequired": false, + "playbackPosition": 0.0, + "owner": { + "ownerType": "user", + "type": "user", + "visibility": "visible", + "id": "68461151", + "name": "ゲスト", + "iconUrl": "https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg" + }, + "requireSensitiveMasking": false, + "videoLive": null, + "isMuted": false + } + } + }, + "smartphone": null, + "system": { + "serverTime": "2025-01-01T00:00:00+09:00", + "isPeakTime": false, + "isStellaAlive": true + }, + "tag": { + "items": [ + { + "name": "テスト", + "isCategory": false, + "isCategoryCandidate": false, + "isNicodicArticleExists": true, + "isLocked": true + }, + { + "name": "テスト動画", + "isCategory": false, + "isCategoryCandidate": false, + "isNicodicArticleExists": true, + "isLocked": true + }, + { + "name": "APIテストタグ68461151-45285955", + "isCategory": false, + "isCategoryCandidate": false, + "isNicodicArticleExists": false, + "isLocked": true + } + ], + "hasR18Tag": false, + "isPublishedNicoscript": false, + "edit": { + "isEditable": true, + "uneditableReason": null, + "editKey": "dummy.jwt.token.for.testing" + }, + "viewer": { + "isEditable": true, + "uneditableReason": null, + "editKey": "dummy.jwt.token.for.testing" + } + }, + "video": { + "id": "sm45285955", + "title": "APIテスト用", + "description": "This is a dummy description for testing purposes.", + "count": { + "view": 1, + "comment": 1, + "mylist": 1, + "like": 1 + }, + "duration": 2, + "thumbnail": { + "url": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006", + "middleUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.M", + "largeUrl": "https://nicovideo.cdn.nimg.jp/thumbnails/45285955/45285955.27227006.L", + "player": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/a960x540l?key=4a2d8a3899b06080a6a9c385fc4d27a709b6c7a48504c4044f68c0ad78e4b905", + "ogp": "https://img.cdn.nimg.jp/s/nicovideo/thumbnails/45285955/45285955.27227006.original/r1280x720l?key=50d3132952586005b060e4d9a1e81bc69097acc4db1119198a962b062c02e3b3" + }, + "rating": { + "isAdult": false + }, + "registeredAt": "2025-01-01T00:00:00+09:00", + "isPrivate": false, + "isDeleted": false, + "isNoBanner": false, + "isAuthenticationRequired": false, + "isEmbedPlayerAllowed": true, + "isGiftAllowed": true, + "viewer": { + "isOwner": true, + "like": { + "isLiked": true, + "count": null + } + }, + "watchableUserTypeForPayment": "all", + "commentableUserTypeForPayment": "all" + }, + "videoAds": { + "additionalParams": { + "videoId": "sm45285955", + "videoDuration": 2, + "isAdultRatingNG": false, + "isAuthenticationRequired": false, + "isR18": false, + "nicosid": "dummy_nicosid_for_testing", + "lang": "ja-jp", + "watchTrackId": "dummy_track_id_for_testing", + "genre": "other", + "gender": "4", + "age": 65 + }, + "items": [ + { + "type": "preroll", + "timingMs": null, + "additionalParams": { + "linearType": "preroll", + "adIdx": 0, + "skipType": 1, + "skippableType": 1, + "pod": 1 + } + }, + { + "type": "postroll", + "timingMs": null, + "additionalParams": { + "linearType": "postroll", + "adIdx": 0, + "skipType": 1, + "skippableType": 1, + "pod": 2 + } + } + ], + "reason": "non_premium_user_ads" + }, + "videoLive": null, + "viewer": { + "id": 68461151, + "nickname": "ゲスト", + "isPremium": false, + "allowSensitiveContents": true, + "existence": { + "age": 65, + "prefecture": "北海道", + "sex": "unanswered" + } + }, + "waku": { + "information": null, + "bgImages": [], + "addContents": null, + "addVideo": null, + "tagRelatedBanner": null, + "tagRelatedMarquee": null, + "pcWatchHeaderCustomBanner": null + } +} diff --git a/tests/providers/nicovideo/fixture_data/shared_types.py b/tests/providers/nicovideo/fixture_data/shared_types.py new file mode 100644 index 0000000000..d6aebcaf4e --- /dev/null +++ b/tests/providers/nicovideo/fixture_data/shared_types.py @@ -0,0 +1,28 @@ +"""Manually managed shared types for fixture system. + +This file contains type definitions that are shared between the fixture +repository and the server repository. Unlike generated files, these are +manually maintained and versioned. +""" + +from __future__ import annotations + +# Pydantic requires runtime type information, so these imports cannot be in TYPE_CHECKING block +from niconico.objects.video.watch import WatchData, WatchMediaDomandAudio # noqa: TC002 +from pydantic import BaseModel + + +class StreamFixtureData(BaseModel): + """Fixture data for stream conversion tests. + + This type is stored in fixtures and reconstructed into StreamConversionData + during test execution with stub values for unstable fields (hls_url, domand_bid, + hls_playlist_text). + + Attributes: + watch_data: Video watch page data from niconico + selected_audio: Selected audio track information + """ + + watch_data: WatchData + selected_audio: WatchMediaDomandAudio diff --git a/tests/providers/nicovideo/fixtures/__init__.py b/tests/providers/nicovideo/fixtures/__init__.py new file mode 100644 index 0000000000..c0a5e60c8d --- /dev/null +++ b/tests/providers/nicovideo/fixtures/__init__.py @@ -0,0 +1 @@ +"""Fixtures package for nicovideo provider tests.""" diff --git a/tests/providers/nicovideo/fixtures/api_response_converter_mapping.py b/tests/providers/nicovideo/fixtures/api_response_converter_mapping.py new file mode 100644 index 0000000000..14cb9eef27 --- /dev/null +++ b/tests/providers/nicovideo/fixtures/api_response_converter_mapping.py @@ -0,0 +1,189 @@ +"""API type to converter function mappings.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from mashumaro import DataClassDictMixin +from niconico.objects.nvapi import ( + FollowingMylistsData, + HistoryData, + LikeHistoryData, + ListSearchData, + OwnVideosData, + RecommendData, + RelationshipUsersData, + SeriesData, + UserVideosData, + VideoSearchData, +) +from niconico.objects.user import NicoUser, UserMylistItem, UserSeriesItem +from niconico.objects.video import EssentialVideo, Mylist +from niconico.objects.video.watch import WatchData +from pydantic import BaseModel + +from music_assistant.providers.nicovideo.converters.stream import ( + StreamConversionData, +) +from tests.providers.nicovideo.fixture_data.shared_types import StreamFixtureData + +if TYPE_CHECKING: + from music_assistant.providers.nicovideo.converters.manager import NicovideoConverterManager + + +# Type definitions for converter results +type SnapshotableItem = DataClassDictMixin +type ConvertedResult = SnapshotableItem | list[SnapshotableItem] | None + + +@dataclass(frozen=True) +class APIResponseConverterMapping[T: BaseModel]: + """Maps API type to converter function.""" + + source_type: type[T] + convert_func: Callable[[T, NicovideoConverterManager], ConvertedResult] + + +# API type to converter function mappings +API_RESPONSE_CONVERTER_MAPPINGS = ( + # Track Types + APIResponseConverterMapping( + source_type=EssentialVideo, + convert_func=lambda data, cm: cm.track.convert_by_essential_video(data), + ), + APIResponseConverterMapping( + source_type=WatchData, + convert_func=lambda data, cm: cm.track.convert_by_watch_data(data), + ), + APIResponseConverterMapping( + source_type=UserVideosData, + convert_func=lambda data, cm: [ + track + for item in data.items + if (track := cm.track.convert_by_essential_video(item.essential)) is not None + ], + ), + APIResponseConverterMapping( + source_type=OwnVideosData, + convert_func=lambda data, cm: [ + track + for item in data.items + if (track := cm.track.convert_by_essential_video(item.essential)) is not None + ], + ), + # Playlist Types + APIResponseConverterMapping( + source_type=Mylist, + convert_func=lambda data, cm: cm.playlist.convert_with_tracks_by_mylist(data), + ), + APIResponseConverterMapping( + source_type=UserMylistItem, + convert_func=lambda data, cm: cm.playlist.convert_by_mylist(data), + ), + APIResponseConverterMapping( + source_type=FollowingMylistsData, + convert_func=lambda data, cm: [ + cm.playlist.convert_following_by_mylist(item) for item in data.mylists + ], + ), + # Album Types + APIResponseConverterMapping( + source_type=SeriesData, + convert_func=lambda data, cm: cm.album.convert_series_to_album_with_tracks(data), + ), + APIResponseConverterMapping( + source_type=UserSeriesItem, + convert_func=lambda data, cm: cm.album.convert_by_series(data), + ), + # Artist Types + APIResponseConverterMapping( + source_type=RelationshipUsersData, + convert_func=lambda data, cm: [ + cm.artist.convert_by_owner_or_user(item) for item in data.items + ], + ), + APIResponseConverterMapping( + source_type=NicoUser, + convert_func=lambda data, cm: cm.artist.convert_by_owner_or_user(data), + ), + # Search Types + APIResponseConverterMapping( + source_type=VideoSearchData, + convert_func=lambda data, cm: [ + track + for item in data.items + if (track := cm.track.convert_by_essential_video(item)) is not None + ], + ), + APIResponseConverterMapping( + source_type=ListSearchData, + convert_func=lambda data, cm: [ + cm.playlist.convert_by_mylist(item) + if item.type_ == "mylist" + else cm.album.convert_by_series(item) + for item in data.items + ], + ), + # History Types + APIResponseConverterMapping( + source_type=HistoryData, + convert_func=lambda data, cm: [ + track + for item in data.items + if (track := cm.track.convert_by_essential_video(item.video)) is not None + ], + ), + APIResponseConverterMapping( + source_type=LikeHistoryData, + convert_func=lambda data, cm: [ + track + for item in data.items + if (track := cm.track.convert_by_essential_video(item.video)) is not None + ], + ), + # Recommendation Types + APIResponseConverterMapping( + source_type=RecommendData, + convert_func=lambda data, cm: [ + track + for item in data.items + if isinstance(item.content, EssentialVideo) + and (track := cm.track.convert_by_essential_video(item.content)) is not None + ], + ), + # Stream Types + APIResponseConverterMapping( + source_type=StreamConversionData, + convert_func=lambda data, cm: cm.stream.convert_from_conversion_data(data), + ), + APIResponseConverterMapping( + source_type=StreamFixtureData, + convert_func=lambda data, cm: cm.stream.convert_from_conversion_data( + StreamConversionData( + watch_data=data.watch_data, + selected_audio=data.selected_audio, + hls_url="https://example.com/stub.m3u8", + domand_bid="stub_bid", + hls_playlist_text="#EXTM3U\n#EXT-X-VERSION:3\n", + ) + ), + ), +) + + +class APIResponseConverterMappingRegistry: + """Maps API response types to converter functions.""" + + def __init__(self) -> None: + """Initialize the registry.""" + self._registry: dict[type, APIResponseConverterMapping[BaseModel]] = {} + for mapping in API_RESPONSE_CONVERTER_MAPPINGS: + self._registry[mapping.source_type] = cast( + "APIResponseConverterMapping[BaseModel]", mapping + ) + + def get_by_type(self, source_type: type) -> APIResponseConverterMapping[BaseModel] | None: + """Get mapping by type with O(1) lookup.""" + return self._registry.get(source_type) diff --git a/tests/providers/nicovideo/fixtures/fixture_loader.py b/tests/providers/nicovideo/fixtures/fixture_loader.py new file mode 100644 index 0000000000..52f1abb5fc --- /dev/null +++ b/tests/providers/nicovideo/fixtures/fixture_loader.py @@ -0,0 +1,58 @@ +"""Fixture management utilities for nicovideo tests.""" + +from __future__ import annotations + +import json +import pathlib +from typing import TYPE_CHECKING, cast + +import pytest + +if TYPE_CHECKING: + from pydantic import BaseModel + +from tests.providers.nicovideo.types import JsonContainer + + +class FixtureLoader: + """Loads and validates test fixtures with type validation.""" + + def __init__(self, fixtures_dir: pathlib.Path) -> None: + """Initialize the fixture manager with the directory containing fixtures.""" + self.fixtures_dir = fixtures_dir + + def load_fixture(self, relative_path: pathlib.Path) -> BaseModel | list[BaseModel] | None: + """Load and validate a JSON fixture against its expected type.""" + data = self._load_json_fixture(relative_path) + + fixture_type = self._get_fixture_type_from_path(relative_path) + if fixture_type is None: + pytest.fail(f"Unknown fixture type for {relative_path}") + + try: + if isinstance(data, list): + return [fixture_type.model_validate(item) for item in data] + else: + # Single object case + return fixture_type.model_validate(data) + except Exception as e: + pytest.fail(f"Failed to validate fixture {relative_path}: {e}") + + def _get_fixture_type_from_path(self, relative_path: pathlib.Path) -> type[BaseModel] | None: + from tests.providers.nicovideo.fixture_data.fixture_type_mappings import ( # noqa: PLC0415 - Because it does not exist before generation + FIXTURE_TYPE_MAPPINGS, + ) + + for key, fixture_type in FIXTURE_TYPE_MAPPINGS.items(): + if relative_path == pathlib.Path(key): + return fixture_type + return None + + def _load_json_fixture(self, relative_path: pathlib.Path) -> JsonContainer: + """Load a JSON fixture file.""" + fixture_path = self.fixtures_dir / relative_path + if not fixture_path.exists(): + pytest.skip(f"Fixture {fixture_path} not found") + + with fixture_path.open("r", encoding="utf-8") as f: + return cast("JsonContainer", json.load(f)) diff --git a/tests/providers/nicovideo/helpers.py b/tests/providers/nicovideo/helpers.py new file mode 100644 index 0000000000..45146168e9 --- /dev/null +++ b/tests/providers/nicovideo/helpers.py @@ -0,0 +1,72 @@ +"""Helper functions for nicovideo tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar +from unittest.mock import Mock + +from music_assistant.providers.nicovideo.converters.manager import NicovideoConverterManager +from tests.providers.nicovideo.types import JsonDict + +if TYPE_CHECKING: + from mashumaro import DataClassDictMixin + from pydantic import JsonValue + +T = TypeVar("T") + + +def create_converter_manager() -> NicovideoConverterManager: + """Create a NicovideoConverterManager for testing.""" + # Create mock provider + mock_provider = Mock() + mock_provider.lookup_key = "nicovideo" + mock_provider.instance_id = "nicovideo_test" + mock_provider.domain = "nicovideo" + + # Create mock logger + mock_logger = Mock() + + return NicovideoConverterManager(mock_provider, mock_logger) + + +def sort_dict_keys_and_lists(obj: JsonValue) -> JsonValue: + """Sort dictionary keys and list elements for consistent snapshot comparison. + + This function ensures deterministic ordering by: + - Sorting dictionary keys alphabetically + - Sorting list elements by type and string representation + + Particularly useful for handling serialized sets that would otherwise have + random ordering between test runs. + """ + if isinstance(obj, dict): + # Sort dictionary keys and recursively process values + return {key: sort_dict_keys_and_lists(obj[key]) for key in sorted(obj.keys())} + elif isinstance(obj, list): + # Recursively process list items first + sorted_items = [sort_dict_keys_and_lists(item) for item in obj] + try: + # Sort items for deterministic ordering (handles serialized sets) + return sorted(sorted_items, key=lambda x: (type(x).__name__, str(x))) + except (TypeError, ValueError): + # If sorting fails, return in original order + return sorted_items + else: + # Return primitive values as-is + return obj + + +def to_dict_for_snapshot(media_item: DataClassDictMixin) -> JsonDict: + """Convert DataClassDictMixin to dict with sorted keys and lists for snapshot comparison.""" + # Get the standard to_dict representation + item_dict = media_item.to_dict() + + # Recursively sort all nested structures, especially sets + sorted_result = sort_dict_keys_and_lists(item_dict) + + # Ensure we return the expected dict type + if isinstance(sorted_result, dict): + return sorted_result + else: + # This should not happen given the input, but satisfies mypy + return item_dict diff --git a/tests/providers/nicovideo/test_converters.py b/tests/providers/nicovideo/test_converters.py new file mode 100644 index 0000000000..b2da44d455 --- /dev/null +++ b/tests/providers/nicovideo/test_converters.py @@ -0,0 +1,218 @@ +"""Generated converter tests using fixture test mappings. + +This module provides automated converter testing for the Nicovideo provider. +The test system is type-safe with automatic fixture updates and parameterized +converter/type specification through common test functions. + +Type System: + - API Responses: Pydantic BaseModel (for JSON validation and fixture saving) + - Converter Results: mashumaro DataClassDictMixin (for snapshot serialization) + +Architecture Overview: + 1. Fixture Collection (fixtures/scripts/api_fixture_collector.py): + - Collects API responses by calling Niconico APIs + - Saves responses as JSON fixtures in generated/fixtures/ + + 2. Type Mapping (fixtures/fixture_type_mapping.py): + - Maps fixture paths to their Pydantic types + - Auto-generates generated/fixture_types.py + + 3. Converter Mapping (fixtures/api_response_converter_mapping.py): + - Defines which converter function to use for each API response type + - Registry provides O(1) type -> converter lookup + + 4. Test Execution (this file): + - Loads fixtures using FixtureLoader + - Applies converters via mapping registry + - Validates results against snapshots + + +Adding New API Endpoints: + See: tests/providers/nicovideo/fixtures/scripts/api_fixture_collector.py + Add collection method and call from collect_all_fixtures() + Note: API response types must inherit from Pydantic BaseModel + + +Adding New Converters: + 1. Implement converter: music_assistant/providers/nicovideo/converters/ + Note: Return types must inherit from mashumaro DataClassDictMixin + 2. Register: music_assistant/providers/nicovideo/converters/manager.py + 3. Add mapping: tests/providers/nicovideo/fixtures/api_response_converter_mapping.py + +""" + +from __future__ import annotations + +import warnings +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from tests.providers.nicovideo.helpers import ( + to_dict_for_snapshot, +) + +if TYPE_CHECKING: + from pydantic import BaseModel + from syrupy.assertion import SnapshotAssertion + + from music_assistant.providers.nicovideo.converters.manager import NicovideoConverterManager + from tests.providers.nicovideo.fixtures.api_response_converter_mapping import ( + APIResponseConverterMappingRegistry, + SnapshotableItem, + ) + from tests.providers.nicovideo.fixtures.fixture_loader import FixtureLoader + + +from .constants import GENERATED_FIXTURES_DIR + + +class ConverterTestRunner: + """Helper class to run converter tests with fixture files.""" + + def __init__( + self, + mapping_registry: APIResponseConverterMappingRegistry, + converter_manager: NicovideoConverterManager, + fixture_loader: FixtureLoader, + snapshot: SnapshotAssertion, + fixtures_dir: Path, + ) -> None: + """Initialize the test runner.""" + self.mapping_registry = mapping_registry + self.converter_manager = converter_manager + self.fixture_loader = fixture_loader + self.snapshot = snapshot + self.fixtures_dir = fixtures_dir + self.failed_tests: list[str] = [] + self.skipped_tests: list[str] = [] + + def run_all_tests(self) -> None: + """Execute converter tests for all fixture files.""" + # Recursively get all JSON files + json_files = list(self.fixtures_dir.rglob("*.json")) + + if not json_files: + pytest.skip("No fixture files found") + + for fixture_path in json_files: + self._process_fixture_file(fixture_path) + + # Report results + self._report_test_results() + + def _process_fixture_file(self, fixture_path: Path) -> None: + """Process a single fixture file.""" + relative_path = fixture_path.relative_to(self.fixtures_dir) + fixture_name = str(relative_path) + + try: + # Load fixture data + fixture_data = self.fixture_loader.load_fixture(relative_path) + if fixture_data is None: + self.failed_tests.append(f"{fixture_name}: Failed to load fixture") + return + + fixture_list = fixture_data if isinstance(fixture_data, list) else [fixture_data] + + for fixture_index, fixture in enumerate(fixture_list): + fixture_id = ( + f"{fixture_name}[{fixture_index}]" if len(fixture_list) > 1 else fixture_name + ) + # fixture is BaseModel type from FixtureLoader.load_fixture + self._process_single_fixture(fixture_id, fixture) + + except Exception as e: + self.failed_tests.append(f"{fixture_name}: {e}") + + def _process_single_fixture(self, fixture_id: str, fixture: BaseModel) -> None: + """Process a single fixture within a fixture file.""" + try: + # Get mapping directly by type + mapping = self.mapping_registry.get_by_type(type(fixture)) + if mapping is None: + # Skip if no mapping found + self.skipped_tests.append(f"{fixture_id}: No mapping for {type(fixture).__name__}") + return + + # Execute test + converted_result = mapping.convert_func(fixture, self.converter_manager) + if converted_result is None: + self.skipped_tests.append(f"{fixture_id}: No conversion result") + return + + # Process all converted items (handles both single and list results) + self._process_all_converted_items(fixture_id, converted_result) + + except Exception as e: + self.failed_tests.append(f"{fixture_id}: {e}") + + def _process_all_converted_items( + self, + base_fixture_id: str, + converted_result: SnapshotableItem | list[SnapshotableItem], + ) -> None: + """Process all items in converted result (handles both single and list).""" + # Convert to list for uniform processing + items = converted_result if isinstance(converted_result, list) else [converted_result] + + for idx, item in enumerate(items): + # Generate unique snapshot ID for each item + snapshot_id = f"{base_fixture_id}_{idx}" if len(items) > 1 else base_fixture_id + self._process_converted_result(snapshot_id, item) + + def _process_converted_result( + self, + snapshot_id: str, + converted: SnapshotableItem, + ) -> None: + """Process a single converted result and compare with snapshot.""" + stable_dict = to_dict_for_snapshot(converted) + + # Compare with snapshot + converted_snapshot = self.snapshot(name=snapshot_id) + snapshot_matches = converted_snapshot == stable_dict + + if not snapshot_matches: + # Get detailed diff information + diff_lines = converted_snapshot.get_assert_diff() + diff_summary = "\n".join(diff_lines[:10]) # Limit to first 10 lines + if len(diff_lines) > 10: + diff_summary += f"\n... ({len(diff_lines) - 10} more lines)" + + self.failed_tests.append( + f"{snapshot_id}: Converted result doesn't match snapshot\nDiff:\n{diff_summary}" + ) + + def _report_test_results(self) -> None: + """Report the final test results.""" + if self.failed_tests: + error_msg = f"Failed tests ({len(self.failed_tests)}):\n" + "\n".join( + f" - {test}" for test in self.failed_tests + ) + pytest.fail(error_msg) + + if self.skipped_tests: + skip_msg = f"Skipped tests ({len(self.skipped_tests)}):\n" + "\n".join( + f" - {test}" for test in self.skipped_tests + ) + warnings.warn(skip_msg, stacklevel=2) + + +def test_converter_with_fixture( + mapping_registry: APIResponseConverterMappingRegistry, + converter_manager: NicovideoConverterManager, + fixture_loader: FixtureLoader, + snapshot: SnapshotAssertion, +) -> None: + """Execute converter tests for all fixture files.""" + runner = ConverterTestRunner( + mapping_registry=mapping_registry, + converter_manager=converter_manager, + fixture_loader=fixture_loader, + snapshot=snapshot, + fixtures_dir=GENERATED_FIXTURES_DIR, + ) + + runner.run_all_tests() diff --git a/tests/providers/nicovideo/types.py b/tests/providers/nicovideo/types.py new file mode 100644 index 0000000000..5288575099 --- /dev/null +++ b/tests/providers/nicovideo/types.py @@ -0,0 +1,13 @@ +"""Type definitions for nicovideo tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pydantic import JsonValue + +# JSON value type alias for better type safety +type JsonDict = dict[str, JsonValue] +type JsonList = list[JsonValue] +type JsonContainer = JsonDict | JsonList