Add niconico video Provider (#2339)
author柴田 <58556078+Shi-553@users.noreply.github.com>
Tue, 25 Nov 2025 13:57:10 +0000 (22:57 +0900)
committerGitHub <noreply@github.com>
Tue, 25 Nov 2025 13:57:10 +0000 (14:57 +0100)
* Initial comit of NicoNico provider

* Remove unwanted logout calls

* niconico: Split and organize classes

* Add artist retrieval method and fix Explorer mixin imports in Niconico provider

* Niconico: Enhance artist parsing with metadata and SNS links

* Niconico: Add sensitive content handling and improve recommendations feature

* Niconico: Implement series handling as albums and enhance library retrieval methods

* Niconico: Refactor the structure of the Nikoniko adapter and split the individual adapters into separate files.

* Niconico: Add URLs for playlists, tracks, and artists in parsers; update import in album mixin

* Refactor Niconico provider mixins and add configuration management

- Enhanced NiconicoMusicProviderExplorerMixin to fetch recommendations with filtering based on user history, following activities, and like history.
- Introduced NiconicoConfig class for managing provider-specific configurations, including recommendation counts and tag filtering.
- Implemented TagManager for caching and retrieving video tags asynchronously, with support for deduplication and periodic cleanup.
- Updated library mixin to support adding and removing tracks with auto-like functionality.
- Improved playlist mixin to handle adding and removing tracks from playlists with error handling.
- Added support for fetching and filtering tracks based on required tags in the track mixin.
- Created a new configuration file for Niconico provider settings.

* Niconico: Update workspace configuration to include additional paths and launch settings

* Niconico: Improve descriptions in configuration entries for clarity and consistency

* Niconico: Simplify get_artist_toptracks method to retrieve the newest 50 tracks

* Niconico: Add auto-sync and include library track artists settings; refactor library mixin

* Niconico: Refactor search methods for improved clarity and efficiency; update configuration entries for email and sensitive content handling

* Refactor Niconico search and user adapters; update parsing methods for improved track retrieval and error handling

* Niconico: Add methods for retrieving own videos and series; update configuration entries for improved content management

* Niconico: Enhance search and recommendation features; refactor configuration and constants for improved tag handling

* Refactor video parsing methods to handle None values and skip muted videos for improved track retrieval

* Remove Niconico server workspace configuration file

* Refactor Niconico provider: Enhance error handling, logging

- Replaced call_with_throttler with _call_with_throttler in NiconicoVideoAdapter for consistency.
- Removed handle_niconico_errors context manager and replaced it with direct logging in various mixins.
- Introduced log_verbose and log_verbose_operation functions for improved logging.
- Updated methods across mixins to utilize new logging functions and streamline error handling.
- Refactored tag management to improve caching and fetching logic.

* Niconico: Update icon SVG files for improved styling and add monochrome version

* Niconico: Renamed parsing to conversion functions for playlists, tracks, and series

* Refactor Niconico video adapter and track mixin: Improve type hints, enhance error handling, and streamline stream format processing

* Refactor type hints in adapter

* Renamed the provider from “niconico” to “nicovideo”.

* Nicovideo: Enhance cache management by limiting concurrent operations to reduce database load

* Nicovideo: Include own uploaded videos in library track retrieval

* Remove unused sensitive contents configuration from Nicovideo provider

* Update NicoVideo monochrome icon SVG with new design and filter effects

* Nicovideo: Add icons for various sections in the music provider explorer mixin

* Nicovideo: Improve tag fetching with async task locking to prevent race conditions

* Nicovideo: Refactor provider mixins and enhance core functionality with MixinCaller

* Nicovideo: Adjust low priority throttler period for improved background tag updates

* Refactor Nicovideo provider: enhance video fetching and tag management, remove TagManager

* Refactor logging in Nicovideo provider: Adjusted log levels.

* Nicovideo: Enhance error logging for ValidationError in API calls

* Refactor Nicovideo provider mixins: streamline library item retrieval and enhance caching logic

* Refactor Nicovideo Adapter Structure and Update Mixins

- Introduced a new NicovideoAdapterHub to centralize adapter functionality.
- Updated mixins to use the new adapter hub instead of individual adapters.
- Implemented utility functions for common operations across converters.
- Improved metadata handling and mapping for media items.
- Ensured compatibility with the latest niconico API changes.

* Refactor NicovideoTrackConverter: Move audio format creation logic to a dedicated method and remove unused utility function

* Refactor Nicovideo provider mixins: rename mixin files.

* Renamed from adapter to service.

* Refactor Nicovideo service architecture: rename Hub to Manager

* Add override decorator to get_stream_details method in NicovideoMusicProvider

* Add override decorators to library methods in artist and track mixins

* Refactor Nicovideo converters: replace utility functions with helper methods

* Remove unused logging import from album mixin

* First implementation of Nicovideo testing.

* Refactor track handling in NicovideoMusicProviderTrackMixin to improve stream metadata retrieval

* requirements: add yt-dlp version 2025.6.30 to dependencies

* fix: update artist mapping to return UniqueList for better handling

* feat: add DUMMY_IS_PEAK_TIME constant and update helper functions to use it; update requirements to include yt-dlp

* feat: update yt-dlp version to 2025.8.11 in manifest and requirements

* feat: update workflow and main.py to latest versions from dev and fixture-updates branches

* Refactor fixture data generation and saving process

- Updated JSON fixture files with dummy descriptions for testing purposes.
- Enhanced helper functions for consistent snapshot comparison and dynamic field stabilization.
- Introduced new scripts for generating and saving fixture data with integrated diff tracking.
- Improved logging for fixture generation and changes.

* fix: update schedule comment to reflect daily execution and enhance condition for fixture updates

* fix: update descriptions in test snapshots to use dummy text for consistency

* fix: remove obsolete check-test-session script

* fix: update release dates and dummy data in test fixtures for consistency

* fix: enhance stabilization logic by consolidating dynamic field and count value processing

* fix: streamline provider unload logic and improve error handling

* Refactoring: refactor niconico service methods to unify series and mylist retrieval logic.Made niconico_py_client accessible via properties in the service.

* Update fixtures-update-and-test.yml

* Refactor: streamline audio format handling by introducing create_audio_format function and centralizing constants

* Refactor: improve stream details handling by safely retrieving HTTP headers and simplifying audio format parameters

* update snapshot

* Fix: Add “extra_input_args” from streamdetails to get_preview_stream and adjust additional input arguments.

* Update fixtures-update-and-test.yml

* Refactoring: Added a stream converter to enhance the nicovideo provider and improve stream detail processing. Removed dependency on yt-dlp.

* - add StabilizationInfo
- Add Stream Test
- Called ruff after automatic generation.

* Fix: Update comment to clarify usage of StreamConversionData serialization in import statement

* Fix: Update media type checks in artist and track mixins for consistency

* Fix: Update search_videos_by_tags method to accept a single tag and streamline video search logic

* Refactoring: Remove unused BaseModel serialization strategy and related imports from helpers.py

* Fix: Rename search_videos_by_tags method to search_videos_by_tag for consistency

* Fix: Update error messages in artist mixin for clarity and detail; add note on playlist duplicate entries

* Fix: Enhance logging with safe argument summaries and caller information extraction

* Fix: Update niconico.py requirement to specific commit for stability

* Fix: Remove follow/unfollow artists functionality from Nicovideo configuration and artist mixin

* Fix: Removed logout upon unloading, and prevented attempts to log in again when authentication information is not available.

* Fix: Remove unnecessary login checks in various mixins and services to streamline data retrieval

* Fix: Enhance album mapping creation with optional thumbnail URL and update artist yield logic to handle ItemMapping conversion

* Fix: Changed to directly generate album and artist mappings.

* Fix: Changed to directly generate album and artist mappings.

* Fix: Update label for user session configuration to clarify cookie usage

* Fix: Remove stage from niconico video manifest

* Fix: Update workflow description to clarify NICONICO_SESSION requirement

* Fix: Rename nicovideo provider fixtures update and test workflow

* Fix: Simplify docstring in get_config_entries and add category comments in config entries

* Fix: Refactor get_config_entries_impl to separate config entry categories and simplify parameters

* Move :nicovideo workflows

* Fix: Update conversion function for SeriesData to handle album tracks

* Fix: Update usage instructions for fixture generation script to reflect correct environment variable

* Fix: Remove unused convert_to_netscape function and related imports

* Fix: Refactor configuration constants for nicovideo provider by moving keys to config.py and cleaning up imports

* Fix: Remove unused DUMMY_DESCRIPTION constant and update stabilization rule for description

* Refactor Nicovideo provider configuration system to use category-based structure

- Introduced a new configuration system for the Nicovideo provider, organizing settings into categories: Auth, Content, and Recommendations.
- Updated all relevant code to access configuration values through the new structure, replacing old method calls with direct attribute access.
- Enhanced clarity and maintainability of the configuration management by encapsulating related settings within dedicated classes.

* Fix: Move recommendation_filter_tags configuration to the correct position in RecommendationsConfigCategory

* Fix: Update recommendation configuration keys and labels for clarity and consistency

* Fix: Add integrated loudness to stream conversion output

* update snapshot loudness

* Add: Added dependency on niconico.py for lint testing.

* Fix: Update niconico.py dependency PEP 508 format in manifest and requirements files

* Remove unused get_supported_features_for_mixin methods

- Remove get_supported_features_for_mixin() from all mixins (base, artist, track, album, playlist, explorer)
- Remove unused ProviderFeature imports
- Features are now statically defined in __init__.py's SUPPORTED_FEATURES

* Simplify config: Remove Content and Recommendations categories

This change addresses PR review feedback requesting a simpler configuration.
The complex Content and Recommendations config categories have been removed
and replaced with sensible defaults:

- Content defaults: Include own mylists, exclude followed mylists
- Recommendations defaults: 25/50/30 track counts, no tag filtering
- Auto-like: Enabled by default
- Sensitive content: Mask by default

The removed complex configuration has been preserved in the
`add-nicovideo-advanced-options` branch for potential future enhancement.

This makes the initial provider setup much simpler while maintaining
core functionality.

* Refactor: Add @override, remove unused filtering, use SENSITIVE_CONTENTS constant

- Add missing @override decorator to get_library_playlists
- Remove _fetch_similar_tracks_with_filtering (no longer filters anything)
- Extract SENSITIVE_CONTENTS constant to nicovideo/constants.py
- Replace all hardcoded sensitive_contents with constant reference

These changes improve code clarity and maintainability.

* Update niconico.py dependency to the latest commit

* refactor: Simplify mixin architecture - remove MixinCaller abstraction

- Remove MixinCaller class with complex lambda-based invoke methods
- Replace with explicit for loops in provider methods
- Make class inheritance explicit (list 6 mixin classes directly)
- Convert provider_mixins/__init__.py to static __all__ list
- Move NICOVIDEO_MIXINS constant to provider.py
- Change reversed() to [::-1] for type checker compatibility

This addresses PR feedback about code complexity while maintaining
mixin-based separation of concerns.

* refactor: Remove provider-specific pre-commit hook

Remove check-nicovideo-test-session hook to maintain provider isolation.
Instead, enforce environment variable usage in fixture generation script:

- Require NICONICO_SESSION environment variable (no hardcoded fallback)
- Add clear error message when variable is not set
- Update documentation to reflect environment-only authentication

This prevents accidental credential commits while keeping the provider
isolated from repository-wide configuration.

* test: Update converter snapshots for new model fields

Update test snapshots to include new fields added to Music Assistant models:
- grouping: Album grouping field
- in_library: Library membership flag

* fix: nicovideo fixtures workflow - use pip instead of uv

- Remove dependency on uv and setup.sh
- Use standard pip installation like test.yml
- Remove unnecessary venv caching
- Simplify workflow by using GitHub Actions default Python environment

* fix: update niconico.py commit hash to latest (711729d)

- Align pyproject.toml with requirements_all.txt
- Fix dependency conflict in GitHub Actions

* refactor: remove nicovideo from mypy exclude list

- nicovideo provider is fully mypy strict mode compliant
- Dependencies managed via manifest.json and requirements_all.txt
- Align with other strict-mode compliant providers (spotify, deezer, etc.)

* fix: resolve mypy strict mode errors in nicovideo provider

- Add proper TYPE_CHECKING import for pydantic BaseModel
- Add explicit type casts to resolve no-any-return errors
- Import Any type for type annotations in tests
- Ensure all return statements have explicit types

* fix: add niconico.py to test dependencies for proper mypy type checking

- Add niconico.py to project.optional-dependencies[test]
- Revert manual type cast workarounds
- Ensures pydantic types from niconico.py are properly resolved during CI
- Simpler and more maintainable than type annotation workarounds

* fix: remove deprecated enable_cache parameter from StreamDetails

- Remove enable_cache parameter removed in music-assistant-models v1.1.61
- This fixes mypy errors after merging upstream/dev changes

* nicovideo snapshots update

* refactor(nicovideo): implement fast HLS seeking with StreamType.CUSTOM

Replace StreamType.HTTP with CUSTOM for optimized seeking support.

Key improvements:
- Parse m3u8 once during stream conversion (not per seek)
- Generate dynamic m3u8 starting from seek segment
- Separate responsibilities: fetch → parse → generate → stream
- Use VERBOSE logging for seek operations
- Organize helpers into directory structure

* nicovideo update test,fixture,snapshot

* Remove FFmpeg submodule directory

* refactor: remove get_stream_details_for_mixin pattern, use normal override in TrackMixin

* docs: add detailed comments explaining StreamType.CUSTOM seek optimization

- Add comprehensive explanation in stream.py for why CUSTOM is used
- Document two-stage seek optimization (segment selection + input-side -ss)
- Explain input-side -ss limitation (can't cross segment boundaries)
- Add stage-by-stage comments in get_audio_stream and create_stream_context
- Clarify performance trade-off vs output-side -ss

* refactor(nicovideo): improve HLS streaming code structure and naming

Major changes:
- Rename NicovideoHLSProcessor → HLSSeekOptimizer for clarity
- Extract HLS data models to separate hls_models.py file
- Standardize m3u8 → playlist terminology throughout codebase
- Add HLS prefix to public API names to avoid confusion with track playlists
- Introduce DOMAND_BID_COOKIE_NAME constant for authentication
- Update method names for better semantic clarity:
  * _get_stream_data → _prepare_conversion_data
  * convert_by_stream_data → convert_from_conversion_data
  * _fetch_hls_m3u8_text → _fetch_media_playlist_text
  * _serve_m3u8 → _serve_hls_playlist

File structure:
- hls_processor.py → hls_seek_optimizer.py (renamed)
- hls_models.py (new): ParsedHLSPlaylist, HLSSegment

All tests and fixtures updated accordingly.

* refactor(nicovideo/tests): improve test module architecture

- Simplify TypeToConverterMappingRegistry (remove 3 unused methods)
- Extract all stabilization logic into FieldStabilizer class
- Add helper method to ConverterTestRunner for clarity
- Document FixtureProcessorProtocol design intent

Reduces complexity while maintaining type safety and auto-update features

* refactor(nicovideo/tests): improve naming and separate concerns

Renamed classes for clarity:
- TypeToConverterMapping → APIResponseConverterMapping
- FixtureManager → FixtureLoader
- FixtureDataGenerators → APIFixtureCollector
- FixtureDataSaver → FixtureSaver
- PathToTypeMapper → FixtureTypeMappingCollector + FixtureTypeMappingFileGenerator

Changes:
- Updated method names: generate_* → collect_* in APIFixtureCollector
- Fixed type completion by changing list to tuple for API_RESPONSE_CONVERTER_MAPPINGS
- Removed re-export pattern from fixtures/__init__.py
- Added comprehensive documentation to test_converters.py

Benefits: clearer responsibilities, better type inference, improved maintainability

* fix: update niconico.py dependency to latest commit

* Refactor: Reorganize fixture structure and update documentation

- Move fixture generation to separate repository (music_assistant_nicovideo_fixtures)
- Restructure fixtures: use fixture_data/ for generated data
- Update fixture loading to use new structure with fixture_type_mappings
- Remove embedded fixture generation scripts from tests
- Add comprehensive README for test directory
- Update VS Code tasks for easier fixture updates
- Delete obsolete GitHub workflow for fixture generation

* refactor: apply @use_cache to nicovideo provider methods

- Apply @use_cache decorator to get_* methods for improved performance
- Remove manual cache handling (cache_track helper and complex album info updates)
- Adjust cache durations based on SoundCloud patterns:
  - get_artist: 14 days (was 30 days)
  - get_track: 14 days (was 7 days)
  - get_playlist: 14 days (was 30 days)
  - get_playlist_tracks: 3 hours (unchanged)
  - search: 3 hours (was 1 hour)
  - get_album methods: 7 days (unchanged)
- Simplify code by leveraging Music Assistant's standard caching system
- Fix imports and add proper noqa comments for @use_cache usage

* revert: remove VS Code launch configuration changes

Remove the 'Music Assistant: Snapshot Update' launch configuration that was
added in previous commits. As requested in PR review, this change belongs
in a separate PR focused on development tools rather than the nicovideo
provider implementation.

* refactor: remove inappropriate cross-controller iteration

Address PR feedback by removing methods that iterate other controllers:
- Remove get_library_tracks and get_library_albums methods
- Remove library_add_for_mixin and library_remove_for_mixin methods
- Remove get_library_artists iteration over tracks controller
- Remove corresponding ProviderFeature flags from SUPPORTED_FEATURES
- Fix imports and TYPE_CHECKING blocks

The provider now only handles its native concepts (followed artists,
mylists, series) without inappropriately accessing other controllers.
This aligns with Music Assistant's architecture where providers should
only return in-library or favorited items from their own domain.

* fix: remove unintended cache category from get_track

Remove category=1 parameter from @use_cache decorator in get_track method
to maintain consistency with other cached methods that use default
category 0. This aligns with the pattern used across other providers
and removes unnecessary complexity.

* fix: Move media item types out of TYPE_CHECKING to fix @use_cache runtime errors

- Move Album, Artist, Track imports from TYPE_CHECKING to regular imports in artist.py
- Add noqa: TC002 comments for @use_cache decorator compatibility
- Format media_items imports in track.py consistently
- Fixes NameError: name 'Artist' is not defined when using @use_cache decorator
- Aligns with other Music Assistant provider patterns

* refactor: improve get_following_activities and fix throttler settings

- Call get_track directly instead of get_provider_item for simplicity
- Remove unnecessary semaphore (throttler already handles rate limiting)
- Fix throttler settings: rate_limit=5, period=1 (5 req/sec) for high priority
- Fix low priority throttler: period=1 for consistency

* refactor: optimize get_following_activities to use direct Activity conversion

- Add convert_by_activity() method to track converter for lightweight feed conversion
- Replace get_track() loop with direct Activity-to-Track conversion
- Remove unnecessary asyncio.gather overhead
- Use ItemMapping for artist info instead of creating Owner objects
- Improves performance by reducing API calls from N+1 to 1

* Update test snapshots for upstream StreamDetails changes

* feat: switch from Git to PyPI reference for niconico.py-ma

- Update pyproject.toml to use niconico.py-ma==2.1.0.post1 from PyPI
- Update requirements_all.txt with PyPI package reference
- Update nicovideo provider manifest.json
- Update fixtures project dependency

This addresses PR feedback to use PyPI packages instead of Git references.

* chore: update fixture data docstring from generator

- Minor docstring update from fixture generation process
- No functional changes

* refactor(nicovideo): address Copilot review feedback

- Simplify to_dict_for_snapshot to assert dict return type
- Switch auth service logging to lazy % formatting
- Fix double space in recommendation folder label
- Document 'additionals' in codespell ignore list

* chore: remove niconico.py-ma dependency from test requirements

* Reimplement get_own_followings to fetch followed artists

Restore the ability to retrieve users that the current user is following,
which was removed during config simplification.

* Remove unused user service methods

Remove methods that are no longer used in the current MA architecture:
- get_following_mylists() - empty stub returning []
- get_following_playlists() - only called get_following_mylists
- follow_user() - no MA interface to call this
- unfollow_user() - no MA interface to call this

These were remnants from config simplification that have no callers.

* Remove unused methods from nicovideo implementation

- Remove NicovideoUserService.get_own_videos()
- Remove NicovideoVideoService.like_video()
- Remove NicovideoSeriesService.get_own_series()
- Remove NicovideoSearchService.search_videos_by_tag()
- Remove unused imports (VideoSearchSortKey, VideoSearchSortOrder)

* refactor: simplify throttler implementation in nicovideo provider

- Use existing helpers/throttle_retry.ThrottlerManager instead of custom implementation
- Remove unused priority control (ApiPriority.LOW was never used)
- Remove verbose logging (sanitization, argument summaries, success logs)
- Keep only error logging with caller info for diagnostics
- Align with other providers' logging patterns (Spotify, Qobuz, Tidal)

* docs: Add explanation for 'Domand' spelling in niconico API

Clarify that 'Domand' is the actual spelling used in niconico's API
(not a typo). This naming appears in API endpoints and throughout
their media delivery system classes.

* docs(nicovideo): clarify HLS seeking limitation in stream.py

Improve technical accuracy of comment explaining why input-side -ss
fails with HLS without playlist reconstruction.

Previous comment incorrectly suggested the issue was about 'crossing
segment boundaries', but the actual problem is that FFmpeg cannot
identify target segments before parsing the playlist structure.

This clarification better reflects the implementation constraint.

---------

Co-authored-by: Shi-553 <58556078+Shi-553@users.noreply.github>
76 files changed:
music_assistant/providers/nicovideo/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/config/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/config/categories/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/config/categories/auth.py [new file with mode: 0644]
music_assistant/providers/nicovideo/config/categories/base.py [new file with mode: 0644]
music_assistant/providers/nicovideo/config/descriptor.py [new file with mode: 0644]
music_assistant/providers/nicovideo/config/factory.py [new file with mode: 0644]
music_assistant/providers/nicovideo/constants.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/album.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/artist.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/base.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/helper.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/manager.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/playlist.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/stream.py [new file with mode: 0644]
music_assistant/providers/nicovideo/converters/track.py [new file with mode: 0644]
music_assistant/providers/nicovideo/helpers/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/helpers/hls_models.py [new file with mode: 0644]
music_assistant/providers/nicovideo/helpers/hls_seek_optimizer.py [new file with mode: 0644]
music_assistant/providers/nicovideo/helpers/utils.py [new file with mode: 0644]
music_assistant/providers/nicovideo/icon.svg [new file with mode: 0644]
music_assistant/providers/nicovideo/icon_monochrome.svg [new file with mode: 0644]
music_assistant/providers/nicovideo/manifest.json [new file with mode: 0644]
music_assistant/providers/nicovideo/provider.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/album.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/artist.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/base.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/core.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/explorer.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/playlist.py [new file with mode: 0644]
music_assistant/providers/nicovideo/provider_mixins/track.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/__init__.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/auth.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/base.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/manager.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/mylist.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/search.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/series.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/user.py [new file with mode: 0644]
music_assistant/providers/nicovideo/services/video.py [new file with mode: 0644]
pyproject.toml
requirements_all.txt
tests/providers/nicovideo/README.md [new file with mode: 0644]
tests/providers/nicovideo/__init__.py [new file with mode: 0644]
tests/providers/nicovideo/__snapshots__/test_converters.ambr [new file with mode: 0644]
tests/providers/nicovideo/conftest.py [new file with mode: 0644]
tests/providers/nicovideo/constants.py [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/__init__.py [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixture_type_mappings.py [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/albums/own_series.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/albums/single_series_details.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/albums/user_series.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/artists/following_users.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/artists/user_details.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/history/user_history.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/history/user_likes.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/playlists/following_mylists.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/playlists/own_mylists.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/playlists/single_mylist_details.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/search/mylist_search.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/search/series_search.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/search/video_search_keyword.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/search/video_search_tags.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/stream/stream_data.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/tracks/own_videos.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/tracks/user_videos.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/fixtures/tracks/watch_data.json [new file with mode: 0644]
tests/providers/nicovideo/fixture_data/shared_types.py [new file with mode: 0644]
tests/providers/nicovideo/fixtures/__init__.py [new file with mode: 0644]
tests/providers/nicovideo/fixtures/api_response_converter_mapping.py [new file with mode: 0644]
tests/providers/nicovideo/fixtures/fixture_loader.py [new file with mode: 0644]
tests/providers/nicovideo/helpers.py [new file with mode: 0644]
tests/providers/nicovideo/test_converters.py [new file with mode: 0644]
tests/providers/nicovideo/types.py [new file with mode: 0644]

diff --git a/music_assistant/providers/nicovideo/__init__.py b/music_assistant/providers/nicovideo/__init__.py
new file mode 100644 (file)
index 0000000..35f84f0
--- /dev/null
@@ -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 (file)
index 0000000..367b24a
--- /dev/null
@@ -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 (file)
index 0000000..fed5bc8
--- /dev/null
@@ -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 (file)
index 0000000..3ba0a0a
--- /dev/null
@@ -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 (file)
index 0000000..7db3562
--- /dev/null
@@ -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 (file)
index 0000000..a12b4f0
--- /dev/null
@@ -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 (file)
index 0000000..51d56c5
--- /dev/null
@@ -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 (file)
index 0000000..5403291
--- /dev/null
@@ -0,0 +1,40 @@
+"""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"
+
+# Note: "Domand" is the actual spelling used in niconico's API (not a typo).
+# This appears in API endpoints like https://asset.domand.nicovideo.jp/ and throughout
+# their media delivery system (WatchMediaDomand, WatchMediaDomandVideo, WatchMediaDomandAudio, etc.)
+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 (file)
index 0000000..b47a588
--- /dev/null
@@ -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 (file)
index 0000000..c576222
--- /dev/null
@@ -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 (file)
index 0000000..d86060e
--- /dev/null
@@ -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 (file)
index 0000000..114be38
--- /dev/null
@@ -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 (file)
index 0000000..32bf9ee
--- /dev/null
@@ -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 (file)
index 0000000..298a6d7
--- /dev/null
@@ -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 (file)
index 0000000..99dedd7
--- /dev/null
@@ -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 (file)
index 0000000..625b741
--- /dev/null
@@ -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 for precise positioning (fine-tune)
+            # Without playlist reconstruction, input-side -ss on HLS results in empty output
+            # because FFmpeg cannot identify target segments before parsing the playlist.
+            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 (file)
index 0000000..4dc4b1e
--- /dev/null
@@ -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 (file)
index 0000000..6809b3b
--- /dev/null
@@ -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 (file)
index 0000000..4c10d6c
--- /dev/null
@@ -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 (file)
index 0000000..f84b9e6
--- /dev/null
@@ -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 (file)
index 0000000..453e823
--- /dev/null
@@ -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 (file)
index 0000000..5a30de5
--- /dev/null
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   version="1.1"
+   id="svg1"
+   width="144"
+   height="144"
+   viewBox="0 0 144 144"
+   sodipodi:docname="144.svg"
+   inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <g
+     inkscape:groupmode="layer"
+     inkscape:label="Image"
+     id="g1">
+    <image
+       width="144"
+       height="144"
+       preserveAspectRatio="none"
+       xlink:href="data:image/png;base64,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&#10;"
+       id="image1" />
+  </g>
+</svg>
diff --git a/music_assistant/providers/nicovideo/icon_monochrome.svg b/music_assistant/providers/nicovideo/icon_monochrome.svg
new file mode 100644 (file)
index 0000000..849a1f3
--- /dev/null
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>\r
+<!-- Created with Inkscape (http://www.inkscape.org/) -->\r
+\r
+<svg\r
+   version="1.1"\r
+   id="svg1"\r
+   width="144"\r
+   height="144"\r
+   viewBox="0 0 144 144"\r
+   sodipodi:docname="144.svg"\r
+   inkscape:version="1.4.2 (f4327f4, 2025-05-13)"\r
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"\r
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"\r
+   xmlns:xlink="http://www.w3.org/1999/xlink"\r
+   xmlns="http://www.w3.org/2000/svg"\r
+   xmlns:svg="http://www.w3.org/2000/svg">\r
+<defs>\r
+  <filter id="binaryWhiteTransparent" color-interpolation-filters="sRGB">\r
+    <!-- RGBをグレースケール化してアルファに -->\r
+    <feColorMatrix type="luminanceToAlpha" result="grayAlpha" />\r
+\r
+    <!-- 画像を白に塗りつぶす -->\r
+    <feFlood flood-color="white" result="whiteFill" />\r
+\r
+    <!-- 二値アルファマスク(しきい値0.5) -->\r
+    <feComponentTransfer in="grayAlpha" result="binaryAlpha">\r
+      <feFuncA type="table" tableValues="1 0"/> <!-- 0〜0.5:1(白) 0.5〜1:0(透明) -->\r
+    </feComponentTransfer>\r
+\r
+    <!-- 白塗りとアルファの合成 -->\r
+    <feComposite in="whiteFill" in2="binaryAlpha" operator="in" result="maskedWhite"/>\r
+\r
+    <!-- 元の画像のアルファ(透過)を保持 -->\r
+    <feComposite in="maskedWhite" in2="SourceGraphic" operator="in" />\r
+  </filter>\r
+</defs>\r
+\r
+  <g\r
+     inkscape:groupmode="layer"\r
+     inkscape:label="Image"\r
+     id="g1">\r
+    <image\r
+       width="144"\r
+       height="144"\r
+       preserveAspectRatio="none"\r
+       filter="url(#binaryWhiteTransparent)"\r
+       xlink:href="data:image/png;base64,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&#10;"\r
+       id="image1" />\r
+  </g>\r
+</svg>\r
diff --git a/music_assistant/providers/nicovideo/manifest.json b/music_assistant/providers/nicovideo/manifest.json
new file mode 100644 (file)
index 0000000..85ec0d1
--- /dev/null
@@ -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-ma==2.1.0.post1"
+  ],
+  "multi_instance": true
+}
diff --git a/music_assistant/providers/nicovideo/provider.py b/music_assistant/providers/nicovideo/provider.py
new file mode 100644 (file)
index 0000000..45e28df
--- /dev/null
@@ -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 (file)
index 0000000..167bc0b
--- /dev/null
@@ -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 (file)
index 0000000..5ac9054
--- /dev/null
@@ -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 (file)
index 0000000..fdad6c5
--- /dev/null
@@ -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 (file)
index 0000000..ac036ea
--- /dev/null
@@ -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 (file)
index 0000000..34177a4
--- /dev/null
@@ -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 (file)
index 0000000..ff2ff25
--- /dev/null
@@ -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 (file)
index 0000000..423c76a
--- /dev/null
@@ -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 (file)
index 0000000..1099288
--- /dev/null
@@ -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 (file)
index 0000000..12a800a
--- /dev/null
@@ -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 (file)
index 0000000..d454c74
--- /dev/null
@@ -0,0 +1,150 @@
+"""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(
+                            "Network or DNS error occurred: %s. Retrying in %d seconds...",
+                            e,
+                            retry_delay_seconds,
+                        )
+                        await asyncio.sleep(retry_delay_seconds)
+                    else:
+                        self.logger.error("An unexpected error has occurred.: %s", e)
+                        return False
+        self.logger.error(
+            "Could not login after exceeding the maximum number of retries (%d).",
+            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 (file)
index 0000000..18eb671
--- /dev/null
@@ -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 (file)
index 0000000..ce11b2d
--- /dev/null
@@ -0,0 +1,132 @@
+"""
+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.converters.manager import (
+    NicovideoConverterManager,
+)
+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)
+
+        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 _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."""
+        try:
+            async with self.niconico_api_throttler.acquire():
+                return await asyncio.to_thread(func, *args, **kwargs)
+        except Exception as err:
+            operation = func.__name__ if hasattr(func, "__name__") else "unknown_function"
+            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 (file)
index 0000000..44fca3c
--- /dev/null
@@ -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 (file)
index 0000000..ed7f694
--- /dev/null
@@ -0,0 +1,123 @@
+"""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,
+)
+
+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
diff --git a/music_assistant/providers/nicovideo/services/series.py b/music_assistant/providers/nicovideo/services/series.py
new file mode 100644 (file)
index 0000000..0f2d916
--- /dev/null
@@ -0,0 +1,82 @@
+"""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_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 (file)
index 0000000..eb9756b
--- /dev/null
@@ -0,0 +1,167 @@
+"""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, Track
+
+    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_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_own_followings(self) -> list[Artist]:
+        """Get users the current user is following and convert them to Artists."""
+        followings_data = await self.service_manager._call_with_throttler(
+            self.niconico_py_client.user.get_own_followings,
+            page_size=25,
+            page=1,
+        )
+
+        if not followings_data or not followings_data.items:
+            return []
+
+        artists = []
+        for user in followings_data.items:
+            artist = self.converter_manager.artist.convert_by_owner_or_user(user)
+            artists.append(artist)
+        return artists
diff --git a/music_assistant/providers/nicovideo/services/video.py b/music_assistant/providers/nicovideo/services/video.py
new file mode 100644 (file)
index 0000000..c9fabc2
--- /dev/null
@@ -0,0 +1,187 @@
+"""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 _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)
index c60c446e76dfec2a8a50780d5ca4cf5dfefb1f99..1dc9ebdfc39b139c4eee8b4cb1193cb12baa172a 100644 (file)
@@ -67,7 +67,8 @@ mass = "music_assistant.__main__:main"
 
 [tool.codespell]
 # explicit is misspelled in the iTunes API
-ignore-words-list = "provid,hass,followings,childs,explict,commitish,"
+# "additionals" is a fixed field name from the Nicovideo API fixtures
+ignore-words-list = "provid,hass,followings,childs,explict,additionals,commitish,"
 skip = """*.js,*.svg,\
 music_assistant/providers/itunes_podcasts/itunes_country_codes.json,\
 """
index f54f5bec74ebfe10ecaed0c24107a70890c5feb3..ce3f0792d8fa8e758b1df21b1415dc5fef9464c5 100644 (file)
@@ -39,6 +39,7 @@ mashumaro==3.17
 music-assistant-frontend==2.17.9
 music-assistant-models==1.1.70
 mutagen==1.47.0
+niconico.py-ma==2.1.0.post1
 orjson==3.11.4
 pillow==11.3.0
 pkce==1.0.3
diff --git a/tests/providers/nicovideo/README.md b/tests/providers/nicovideo/README.md
new file mode 100644 (file)
index 0000000..4c90c69
--- /dev/null
@@ -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 (file)
index 0000000..e8fdfc7
--- /dev/null
@@ -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 (file)
index 0000000..b563095
--- /dev/null
@@ -0,0 +1,2498 @@
+# 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,
+      'elapsed_time': None,
+      'elapsed_time_last_updated': 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 (file)
index 0000000..ebd0b1e
--- /dev/null
@@ -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 (file)
index 0000000..fcc165c
--- /dev/null
@@ -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 (file)
index 0000000..c0a5e60
--- /dev/null
@@ -0,0 +1 @@
+"""Fixtures 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 (file)
index 0000000..83ce668
--- /dev/null
@@ -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 (file)
index 0000000..e2b4db3
--- /dev/null
@@ -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 (file)
index 0000000..ac3f6b4
--- /dev/null
@@ -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 (file)
index 0000000..e2b4db3
--- /dev/null
@@ -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 (file)
index 0000000..cddc40c
--- /dev/null
@@ -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 (file)
index 0000000..6a576cd
--- /dev/null
@@ -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 (file)
index 0000000..42b0cc6
--- /dev/null
@@ -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 (file)
index 0000000..eb7b68a
--- /dev/null
@@ -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 (file)
index 0000000..ecf78eb
--- /dev/null
@@ -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 (file)
index 0000000..521cace
--- /dev/null
@@ -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 (file)
index 0000000..34074ae
--- /dev/null
@@ -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 (file)
index 0000000..a6b5e9a
--- /dev/null
@@ -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 (file)
index 0000000..1aa3046
--- /dev/null
@@ -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 (file)
index 0000000..e000866
--- /dev/null
@@ -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 (file)
index 0000000..6d0bc2a
--- /dev/null
@@ -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 (file)
index 0000000..b6513c6
--- /dev/null
@@ -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 (file)
index 0000000..bf60193
--- /dev/null
@@ -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 (file)
index 0000000..d0bcd1b
--- /dev/null
@@ -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 (file)
index 0000000..13df6d5
--- /dev/null
@@ -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 (file)
index 0000000..d6aebca
--- /dev/null
@@ -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 (file)
index 0000000..c0a5e60
--- /dev/null
@@ -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 (file)
index 0000000..14cb9ee
--- /dev/null
@@ -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 (file)
index 0000000..52f1abb
--- /dev/null
@@ -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 (file)
index 0000000..a27b7e7
--- /dev/null
@@ -0,0 +1,69 @@
+"""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
+    assert isinstance(sorted_result, dict)
+    return sorted_result
diff --git a/tests/providers/nicovideo/test_converters.py b/tests/providers/nicovideo/test_converters.py
new file mode 100644 (file)
index 0000000..b2da44d
--- /dev/null
@@ -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 (file)
index 0000000..5288575
--- /dev/null
@@ -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