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