detain / phlix-plugin-myanimelist
MyAnimeList metadata provider — anime titles, descriptions, episodes, ratings via the MAL API v2.
Package info
github.com/detain/phlix-plugin-myanimelist
Type:phlix-plugin
pkg:composer/detain/phlix-plugin-myanimelist
Requires
- php: >=8.3
- ext-mbstring: *
- ext-openssl: *
- detain/phlix-shared: ^0.21.0
- psr/container: ^1.1 || ^2.0
- psr/log: ^1.0 || ^2.0 || ^3.0
- workerman/http-client: ^3.1
Requires (Dev)
- phpstan/extension-installer: *
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-02 14:35:15 UTC
README
MyAnimeList metadata provider plugin for Phlix — anime titles, descriptions, episodes, ratings via the MyAnimeList API v2.
Overview
This plugin fetches structured anime metadata from MyAnimeList
using the official MAL API v2 (https://api.myanimelist.net/v2):
- Search (
GET /anime?q=...) — resolve a title to a MAL anime ID (best match) - Details (
GET /anime/{id}?fields=...) — fetch titles, synopsis, episodes, rating, studio
Every request carries an X-MAL-CLIENT-ID header with your MAL client ID.
Features
- Title search — query MAL for the best-matching anime ID
- Full metadata — primary/English/Japanese titles, synonyms, genres, year, type, rating
- Episode info — episode count and average episode runtime
- Synopsis — long-form description text
- No SDK — plain HTTP/JSON against the MAL REST API via the vendored
workerman/http-clientasync client (Workerman\Http\Clientover pooled HTTPS), not PHP stream wrappers. Runtime requires:workerman/http-client,detain/phlix-shared,psr/container,psr/log— seecomposer.json(which is why Install step 4 runscomposer install).
Install
The plugin is unsigned by design. Install via the Phlix admin UI:
-
Log in to your Phlix server as an admin user (
users.is_admin = 1). -
Browse to
/admin/plugins. -
Paste this URL into the Install from URL form:
https://raw.githubusercontent.com/detain/phlix-plugin-myanimelist/master/plugin.json -
The server downloads and validates the manifest, runs
composer install --no-dev, and stores a row in thepluginstable. -
Configure your MyAnimeList client ID in the plugin settings form.
-
Enable the plugin.
Getting a MAL Client ID
- Sign in at myanimelist.net.
- Go to API → Create ID.
- Create an app and copy the Client ID (the Client Secret is not needed for read-only metadata).
Configuration
Configure these in the Phlix admin Plugins → Configure dialog.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
client_id |
string (secret) | Yes | — | Your MyAnimeList API Client ID, sent as the X-MAL-CLIENT-ID header. |
use_ssl_verification |
boolean | No | true |
Verify TLS certificates when calling the MAL API. |
The plugin validates client_id when it is constructed: enabling it with a
blank or missing Client ID throws immediately at that boundary instead of
storing the credential and failing later on every request.
Where to get your Client ID
Create an API application at myanimelist.net/apiconfig
(App Type "web" or "other"), then copy its Client ID into client_id.
How It Works
onEnable registers the {@see MyanimelistMetadataProviderAdapter} with the host's
MetadataManager, which then drives the standard triad against the adapter:
- Search —
GET /anime?q=<title>&limit=10&fields=id,title,alternative_titles. Every candidate is scored against all of its titles (exact > prefix > contains, including English/Japanese/synonym alternatives) and the single best match is returned — MAL's popularity ranking is not taken at face value. - Details —
GET /anime/{id}?fields=...for full anime data. - Images — poster (
main_picture) + fanart (firstpicturesentry).
The legacy lookup($filePath) entry point (filename parsing → search → details) is still
implemented for interface parity, but the host does not call it.
MAL API Notes
- Protocol: REST/JSON over HTTPS to
https://api.myanimelist.net/v2 - Auth: every request sends
X-MAL-CLIENT-ID: <client_id> - Redirects: refused outright (
allow_redirects => ['max' => 0]) — the credential header is never re-issued to a redirect target; a 3xx is treated as a failed lookup - Search:
GET /anime?q=<query>&limit=10&fields=id,title,alternative_titles→{ "data": [ { "node": { "id", "title", ... } } ] } - NSFW default-deny: requests never send
nsfw/nsfw_all, so MAL omits adult entries from search and refuses them via details — adult variants simply never match. The detailsratingclassification (g,pg-13,r-17,rx) is still surfaced asadult_ratingon the metadata map so a host that resolves entries by other means can apply its own gate - Details:
GET /anime/{id}?fields=id,title,main_picture,alternative_titles,start_date,synopsis,mean,num_scoring_users,genres,num_episodes,media_type,status,studios,average_episode_duration,rating,pictures(picturesis what makesfanart_urlpossible — drop it and fanart silently becomes null) fields=is the headline MAL gotcha: MAL returns nested data (alternative_titles,genres,studios,pictures, …) only for fields that are explicitly listed in thefields=parameter, on both the details and search endpoints. Trimming a field from the list fails SILENTLY — no error, no warning — the metadata just degrades: blank genres/studio, null fanart, scoring reduced to main-title matching (English/Japanese/ synonym alternatives disappear), andadult_ratingvanishes. Every key the parsers read is requested; if you editDETAIL_FIELDSor the searchfields=, keep the consumed set complete.- Rate limiting: ~1 request/second; a
Retry-Afteron 429/503 is honored but clamped to 300s (non-finite header values fall back to the default interval) so one pathological response can never park a resident worker - Caching: decoded 2xx bodies are cached per URL for 1h (TTL) with the
cache capped at 512 entries (oldest evicted); the cache is also dropped on
disable, so bodies fetched under a rotated
client_idare not replayed
See the MAL API v2 reference for full details.
Data Returned
[
'title' => 'Cowboy Bebop', // Primary title
'original_name' => 'カウボーイビバップ', // Japanese title (falls back to title)
'overview' => 'In the year 2071...', // Synopsis
'year' => 1998, // First-aired year
'genres' => ['Action', 'Sci-Fi'], // Genre names
'rating' => 8.75, // MAL mean score (0-10)
'vote_count' => 900000, // Number of scoring users
'poster_url' => 'https://cdn.myanimelist.net/images/anime/4/19644l.jpg',
'fanart_url' => 'https://cdn.myanimelist.net/images/anime/1904/120844.jpg', // first `pictures` entry (large preferred, medium fallback); null when the anime has none
'episodes' => 26, // Episode count
'type' => 'tv', // tv / movie / ova / special / ona / music
'mal_id' => 1, // MyAnimeList anime ID
'titles' => ['Cowboy Bebop', 'カウボーイビバップ', 'Cowboy Bebop (1998)'],
'status' => 'Finished', // Finished / Currently Airing / Upcoming
'runtime_ticks' => 14400000000, // Avg episode length in ticks (1s = 10,000,000)
'studio' => 'Sunrise', // First studio name
]
A no-match returns [].
Fork as a Starter
This plugin is based on phlix-plugin-example. To create your own metadata provider:
- Fork or copy this repository.
- Edit
plugin.json— pick a newname(must start withphlix-plugin-), setversionto your own starting version (a new fork begins its own line, e.g.0.1.0— this repository is already at0.3.0, so "start at 0.1.0" is a fresh line, not a downgrade), changeentryto your FQCN. - Edit
composer.json— rename the package, update PSR-4 autoload prefix. - Replace
src/MyanimelistMetadataProvider.phpwith your own implementation — and handlesrc/MyanimelistMetadataProviderAdapter.phptoo: itsusestatements and type-hints pin the provider FQCN, so it must be replaced or renamed along with the provider it wraps. - Run tests:
composer install && vendor/bin/phpunit.
Testing
composer install
vendor/bin/phpunit
vendor/bin/phpunit --testdox # verbose output
The unit tests exercise the parse/map helpers via Reflection with fixture
JSON — no live network calls are made; the HTTP paths covered by
tests/Unit/MyanimelistTransportTest.php and
tests/Unit/MyanimelistMetadataProviderAdapterTest.php run through anonymous
\Workerman\Http\Client subclasses instead.
phpunit.xml declares a Cobertura report, so a run with a coverage driver also
writes coverage.xml; .github/workflows/test.yml runs the suite on PHP 8.3
and 8.4 and uploads that file to Codacy.
Static analysis (PHPStan level 9)
The CI workflow gates src/ at PHPStan level 9 with no exclusions:
vendor/bin/phpstan analyse src --level=9 # the CI command verbatim vendor/bin/phpstan analyse -c phpstan.neon # same result, config-driven
phpstan.neon pins level: 9, paths: [src], and bootstraps
tests/Stub/MetadataProviderInterface.php — the same verbatim test stub
tests/bootstrap.php loads — so the adapter (which implements the host
server's metadata contract, absent from this repo's dependencies) analyzes
cleanly instead of being excluded. Both invocations resolve the same
configuration: the explicit --level=9 command still auto-loads
phpstan.neon for bootstrapFiles.
Security Audit
.github/workflows/test.yml also runs a blocking composer-audit job that
executes scripts/security-audit-check.php. It audits the whole lock — both
require and require-dev — via composer audit --locked --format=json,
labels each finding [require] or [require-dev], and fails if the audited
corpus drops below its floors:
php scripts/security-audit-check.php
There is no baseline file and no ignore list in the script; the only escape
hatch is config.audit.ignore in composer.json, which the gate reports back
as a loud IGNORED notice. tests/Unit/SecurityAuditCheckTest.php pins that
policy shut.
License
MIT — see LICENSE.