Skip to content

feat(actions): implement plugin-registrable action system#1383

Draft
revam wants to merge 3 commits into
masterfrom
feat/plugin-registrable-actions
Draft

feat(actions): implement plugin-registrable action system#1383
revam wants to merge 3 commits into
masterfrom
feat/plugin-registrable-actions

Conversation

@revam

@revam revam commented Jul 12, 2026

Copy link
Copy Markdown
Member

Implemented the plugin-registrable actions system as specified by the RFC
discussion #1380, allowing plugins (and core) to register discrete, invokable
units of work that appear in the Actions menu and can be triggered by ID via the
API.

Abstractions layer (Shoko.Abstractions.Action):

  • Added IExecutableAction base interface with Name, Description,
    Category (hard enum ActionCategory, defaults to PluginInferred), and
    RequiresConfirmation properties.
  • Added scope sub-interfaces: IExecutableGlobalAction,
    IExecutableGlobalUserAction, IExecutableGroupAction,
    IExecutableGroupUserAction, IExecutableSeriesAction,
    IExecutableSeriesUserAction, IExecutableEpisodeAction,
    IExecutableEpisodeUserAction.
  • Added ExecutableActionInfo DTO with stable UUIDv5 derived from class name +
    plugin ID namespace.
  • Added IActionService for registration (AddParts), discovery
    (GetActions), direct execution, and queued scheduling.
  • Expanded ActionCategory enum with explicit hex values (e.g.
    Maintenance = 0xF1, Mischievous = 0xF2, Destructive = 0xFE,
    PluginInferred = 0xFF) and doc comments on all members.
  • Added ActionScope enum with Global = 1, GlobalUser = 2, Group = 3,
    GroupUser = 4, Series = 5, SeriesUser = 6, Episode = 7,
    EpisodeUser = 8.

Registration & DI:

  • PluginManager.InitPlugins() calls IActionService.AddParts() via
    GetExports<IExecutableAction>, same pattern as other plugin extension
    points.
  • Action instances are transient via ActivatorUtilities; lifetime management
    is the plugin's responsibility.
  • Stores action types, not cached instances; fresh instance created from type on
    each execution via IPluginManager.GetExport<T>.

Service layer (ActionService):

  • Implements IActionService with _registeredActions list.
  • AddParts resolves plugin info, derives UUIDs, collects all matching scopes
    via typeof(T).IsAssignableFrom, and propagates RequiresConfirmation -
    GetActions accepts IEnumerable<ActionScope>?,
    IEnumerable<ActionCategory>?, IEnumerable<string>? for set-based filtering
    (scope overlap, category/category-name contains).
  • Result ordering: category value (ensures deterministic core category
    ordering), then category name (ensures deterministic plugin ordering), then
    name (ensures deterministic action ordering).
  • Execute* methods resolve a fresh instance each time, validate scope presence
    upfront, throw InvalidOperationException on mismatch - ScheduleExecuteOf*
    validates scope before enqueuing via ExecuteActionJob.
  • Added 7 new methods migrated from the API controller: RunImport_SyncVotes,
    PurgeAllTmdbLinks, UpdateAniDBCalendar, PlexSyncAll,
    AddAllManualLinksToMyList, GetAniDBNotifications, AVDumpMismatchedFiles.

Global action implementations (Shoko.Server.Services.Actions):

  • 35 IExecutableGlobalAction classes across 5 domains:
    • Import (3): RunImport, ImportNewFiles, RemoveMissingFiles
    • AniDB (11): DownloadMissingAniDBAnimeData, Creators,
      UpdateAllAniDBInfo, UpdateMissingAniDBFileInfo,
      RefreshAniDBMovedFiles, VerifyAllRelations, SyncAniDBMyList,
      SyncAniDBVotes, UpdateAniDBCalendar,
      AddAllManualLinksToMyList, GetAniDBNotifications,
      AVDumpMismatchedFiles
    • TMDB (10): SearchForMatches, UpdateAllTMDBMovies (+WithImages),
      UpdateAllTMDBShows (+WithImages), DownloadMissingPeople,
      PurgeAllUnusedMovies, Collections, Shows,
      ShowAlternateOrderings, PurgeAllLinks
    • Images (3): UpdateAllImages, ValidateAllImages,
      PurgeAllUnusedTMDBImages
    • Maintenance (8): UpdateAllMediaInfo, UpdateSeriesStats,
      RecreateAllGroups, RenameAllGroups, CreateMissingSeries,
      PurgeAllUsedReleases, PurgeAllUnusedReleases, PlexSyncAll
  • Destructive actions set RequiresConfirmation = true
  • RunImportAction and SyncAnidbMyListAction use IJobFactory

Series action implementations (Shoko.Server.Services.Actions.*.Series):

  • 17 IExecutableSeriesAction classes across 4 sub-namespaces:
    • AniDB.Series (4): UpdateAnidbInfo (cache+remote fallback),
      UpdateAnidbInfoRemote (remote, respects checks),
      UpdateAnidbInfoForce (bypasses time check + HTTP bans,
      requires confirmation), UpdateAnidbInfoXmlCache (local XML only)
    • Tmdb.Series (7): AutoSearchTmdbMatch, UpdateTmdbInfo,
      UpdateTmdbImagesForce, RefreshTmdbMovies,
      DownloadTmdbMovieImages, AutoMatchTmdbEpisodes,
      ResetTmdbEpisodeMappings (requires confirmation)
    • Import.Series (3): RescanFiles, RehashFiles, RelocateFiles
    • Destructive.Series (3): DeleteKeepFiles, DeleteRemoveFiles,
      DeleteAllData (all require confirmation)
  • Actions are self-contained: they inject services directly and
    operate on the IShokoSeries passed at invocation

Group action implementations (Shoko.Server.Services.Actions.Import.Group):

  • 3 IExecutableGroupAction classes mirroring the series file actions:
    RescanGroupFiles, RehashGroupFiles, RelocateGroupFiles
  • Each iterates all series within the group to operate on their files

Queue job (ExecuteActionJob):

  • Stores ActionID, UserID, GroupID, AnimeID, and EpisodeID
  • Uses PostInit to hydrate display names via IActionService,
    IUserService, and IMetadataService
  • ScopeToName infers the scope label from which of
    (GroupID, AnimeID, EpisodeID, UserID) are set,
    covering all 8 scope combinations
  • Routes to the appropriate IActionService.Execute* method

API layer (v3):

  • GET /api/v3/Action — lists global/user-scoped actions grouped
    by CategoryName (B2 shape), filtered by user permissions
  • POST /api/v3/Action/{id}/Execute?scope= — invokes a
    global/user action; scope defaults by role priority; admin check
    for Global/Series/Group/Episode, any auth for user-scoped variants
  • GET /api/v3/Action/Group/{groupID}/Actions — lists
    group-scoped actions
  • POST /api/v3/Action/Group/{groupID}/Actions/{id}/Execute?scope=
    — invokes a group/group-user action with group existence validation
  • GET /api/v3/Action/Series/{seriesID}/Actions — lists
    series-scoped actions
  • POST /api/v3/Action/Series/{seriesID}/Actions/{id}/Execute?scope=
    — invokes a series/series-user action with series existence
    validation
  • GET /api/v3/Action/Episode/{episodeID}/Actions — lists
    episode-scoped actions (hookup for future)
  • POST /api/v3/Action/Episode/{episodeID}/Actions/{id}/Execute?scope=
    — invokes an episode/episode-user action with episode existence
    validation
  • Scope-aware auth: non-admin users only see GlobalUser,
    GroupUser, SeriesUser, EpisodeUser scopes in listings
  • Plugin info included in ActionInfo response DTO
  • Old hardcoded endpoints marked [Obsolete]; implementation
    delegated to ActionService

Differences from RFC #1380:

  • Execute remains parameterless for all scopes; entity/user context
    supplied by caller via IActionService overloads (consistent with
    Gap 18 resolution)
  • Scope enum is flat (not flags), with 8 discrete values for each
    entity level (Global/Group/Series/Episode) and their user variants
    — extends the RFC's original Global/Series scope pair
  • GetActions uses set-based overlap filtering instead of single
    scope matching
  • CategoryName string on ExecutableActionInfo carries the display
    label; no separate group description in API responses
  • RequiresConfirmation added as a UI hint for destructive actions
    (not specified in RFC but a natural follow-on)
  • Direct execution (Execute* methods) exists on the service for
    internal use, but the API always schedules (per RFC Gap 15)
  • ActionCategory default changed from Mischievous to
    PluginInferred, so actions without an explicit category are
    grouped by their plugin's name

Refs: #1380

@revam revam requested a review from hidden4003 July 12, 2026 19:53
Implemented the plugin-registrable actions system as specified by the RFC
discussion #1380, allowing plugins (and core) to register discrete, invokable
units of work that appear in the Actions menu and can be triggered by ID via the
API.

**Abstractions layer** (Shoko.Abstractions.Action):
- Added `IExecutableAction` base interface with `Name`, `Description`,
  `Category` (hard enum `ActionCategory`, defaults to `PluginInferred`), and
  `RequiresConfirmation` properties.
- Added scope sub-interfaces: `IExecutableGlobalAction`,
  `IExecutableGlobalUserAction`, `IExecutableGroupAction`,
  `IExecutableGroupUserAction`, `IExecutableSeriesAction`,
  `IExecutableSeriesUserAction`, `IExecutableEpisodeAction`,
  `IExecutableEpisodeUserAction`.
- Added `ExecutableActionInfo` DTO with stable UUIDv5 derived from class name +
  plugin ID namespace.
- Added `IActionService` for registration (`AddParts`), discovery
  (`GetActions`), direct execution, and queued scheduling.
- Expanded `ActionCategory` enum with explicit hex values (e.g.
  `Maintenance = 0xF1`, `Mischievous = 0xF2`, `Destructive = 0xFE`,
  `PluginInferred = 0xFF`) and doc comments on all members.
- Added `ActionScope` enum with `Global = 1`, `GlobalUser = 2`, `Group = 3`,
  `GroupUser = 4`, `Series = 5`, `SeriesUser = 6`, `Episode = 7`,
  `EpisodeUser = 8`.

**Registration & DI**:
- `PluginManager.InitPlugins()` calls `IActionService.AddParts()` via
  `GetExports<IExecutableAction>`, same pattern as other plugin extension
  points.
- Action instances are transient via `ActivatorUtilities`; lifetime management
  is the plugin's responsibility.
- Stores action types, not cached instances; fresh instance created from type on
  each execution via `IPluginManager.GetExport<T>`.

**Service layer** (`ActionService`):
- Implements `IActionService` with `_registeredActions` list.
- `AddParts` resolves plugin info, derives UUIDs, collects all matching scopes
  via `typeof(T).IsAssignableFrom`, and propagates `RequiresConfirmation` -
  `GetActions` accepts `IEnumerable<ActionScope>?`,
  `IEnumerable<ActionCategory>?`, `IEnumerable<string>?` for set-based filtering
  (scope overlap, category/category-name contains).
- Result ordering: category value (ensures deterministic core category
  ordering), then category name (ensures deterministic plugin ordering), then
  name (ensures deterministic action ordering).
- `Execute*` methods resolve a fresh instance each time, validate scope presence
  upfront, throw `InvalidOperationException` on mismatch - `ScheduleExecuteOf*`
  validates scope before enqueuing via `ExecuteActionJob`.
- Added 7 new methods migrated from the API controller: `RunImport_SyncVotes`,
  `PurgeAllTmdbLinks`, `UpdateAniDBCalendar`, `PlexSyncAll`,
  `AddAllManualLinksToMyList`, `GetAniDBNotifications`, `AVDumpMismatchedFiles`.

**Global action implementations** (Shoko.Server.Services.Actions):
- 35 `IExecutableGlobalAction` classes across 5 domains:
  - Import (3): RunImport, ImportNewFiles, RemoveMissingFiles
  - AniDB (11): DownloadMissingAniDBAnimeData, Creators,
    UpdateAllAniDBInfo, UpdateMissingAniDBFileInfo,
    RefreshAniDBMovedFiles, VerifyAllRelations, SyncAniDBMyList,
    SyncAniDBVotes, UpdateAniDBCalendar,
    AddAllManualLinksToMyList, GetAniDBNotifications,
    AVDumpMismatchedFiles
  - TMDB (10): SearchForMatches, UpdateAllTMDBMovies (+WithImages),
    UpdateAllTMDBShows (+WithImages), DownloadMissingPeople,
    PurgeAllUnusedMovies, Collections, Shows,
    ShowAlternateOrderings, PurgeAllLinks
  - Images (3): UpdateAllImages, ValidateAllImages,
    PurgeAllUnusedTMDBImages
  - Maintenance (8): UpdateAllMediaInfo, UpdateSeriesStats,
    RecreateAllGroups, RenameAllGroups, CreateMissingSeries,
    PurgeAllUsedReleases, PurgeAllUnusedReleases, PlexSyncAll
- Destructive actions set `RequiresConfirmation = true`
- `RunImportAction` and `SyncAnidbMyListAction` use `IJobFactory`

**Series action implementations** (Shoko.Server.Services.Actions.*.Series):
- 17 `IExecutableSeriesAction` classes across 4 sub-namespaces:
  - AniDB.Series (4): UpdateAnidbInfo (cache+remote fallback),
    UpdateAnidbInfoRemote (remote, respects checks),
    UpdateAnidbInfoForce (bypasses time check + HTTP bans,
    requires confirmation), UpdateAnidbInfoXmlCache (local XML only)
  - Tmdb.Series (7): AutoSearchTmdbMatch, UpdateTmdbInfo,
    UpdateTmdbImagesForce, RefreshTmdbMovies,
    DownloadTmdbMovieImages, AutoMatchTmdbEpisodes,
    ResetTmdbEpisodeMappings (requires confirmation)
  - Import.Series (3): RescanFiles, RehashFiles, RelocateFiles
  - Destructive.Series (3): DeleteKeepFiles, DeleteRemoveFiles,
    DeleteAllData (all require confirmation)
- Actions are self-contained: they inject services directly and
  operate on the `IShokoSeries` passed at invocation

**Group action implementations** (Shoko.Server.Services.Actions.Import.Group):
- 3 `IExecutableGroupAction` classes mirroring the series file actions:
  RescanGroupFiles, RehashGroupFiles, RelocateGroupFiles
- Each iterates all series within the group to operate on their files

**Queue job** (`ExecuteActionJob`):
- Stores `ActionID`, `UserID`, `GroupID`, `AnimeID`, and `EpisodeID`
- Uses `PostInit` to hydrate display names via `IActionService`,
  `IUserService`, and `IMetadataService`
- `ScopeToName` infers the scope label from which of
  (GroupID, AnimeID, EpisodeID, UserID) are set,
  covering all 8 scope combinations
- Routes to the appropriate `IActionService.Execute*` method

**API layer** (v3):
- `GET /api/v3/Action` — lists global/user-scoped actions grouped
  by `CategoryName` (B2 shape), filtered by user permissions
- `POST /api/v3/Action/{id}/Execute?scope=` — invokes a
  global/user action; scope defaults by role priority; admin check
  for Global/Series/Group/Episode, any auth for user-scoped variants
- `GET /api/v3/Action/Group/{groupID}/Actions` — lists
  group-scoped actions
- `POST /api/v3/Action/Group/{groupID}/Actions/{id}/Execute?scope=`
  — invokes a group/group-user action with group existence validation
- `GET /api/v3/Action/Series/{seriesID}/Actions` — lists
  series-scoped actions
- `POST /api/v3/Action/Series/{seriesID}/Actions/{id}/Execute?scope=`
  — invokes a series/series-user action with series existence
  validation
- `GET /api/v3/Action/Episode/{episodeID}/Actions` — lists
  episode-scoped actions (hookup for future)
- `POST /api/v3/Action/Episode/{episodeID}/Actions/{id}/Execute?scope=`
  — invokes an episode/episode-user action with episode existence
  validation
- Scope-aware auth: non-admin users only see `GlobalUser`,
  `GroupUser`, `SeriesUser`, `EpisodeUser` scopes in listings
- Plugin info included in `ActionInfo` response DTO
- Old hardcoded endpoints marked `[Obsolete]`; implementation
  delegated to `ActionService`

**Differences from RFC #1380**:
- `Execute` remains parameterless for all scopes; entity/user context
  supplied by caller via `IActionService` overloads (consistent with
  Gap 18 resolution)
- Scope enum is flat (not flags), with 8 discrete values for each
  entity level (Global/Group/Series/Episode) and their user variants
  — extends the RFC's original Global/Series scope pair
- `GetActions` uses set-based overlap filtering instead of single
  scope matching
- `CategoryName` string on `ExecutableActionInfo` carries the display
  label; no separate group description in API responses
- `RequiresConfirmation` added as a UI hint for destructive actions
  (not specified in RFC but a natural follow-on)
- Direct execution (`Execute*` methods) exists on the service for
  internal use, but the API always schedules (per RFC Gap 15)
- Actions store types, not instances; fresh instance per invocation
- `ActionCategory` default changed from `Mischievous` to
  `PluginInferred`, so actions without an explicit category are
  grouped by their plugin's name

Refs: #1380
@revam revam force-pushed the feat/plugin-registrable-actions branch from 6e989ca to 5b36ce8 Compare July 12, 2026 21:51
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4.0% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@revam revam marked this pull request as draft July 13, 2026 01:36
@hidden4003

Copy link
Copy Markdown
Member
  1. Scope enum ballooned: spec resolved ActionScope as exactly Global | Series (Gap 20, brand new in v3). The PR ships 8 flat values — Global/GlobalUser/Group/GroupUser/Series/SeriesUser/Episode/EpisodeUser — introducing Group and Episode scoping and "User" variants of every level. None of that exists in the spec at all. This is the biggest scope expansion in the PR.

@revam Why do we need that many?

  1. Permissions model contradicts Gap 8/19 as written: the RFC was explicit and re-confirmed twice — "all actions require admin privileges... no per-action role granularity," and for series actions specifically, "no basis for opening them to non-admin users" since they act on shared objects. The PR's ...User scope variants are explicitly non-admin ("any auth for user-scoped variants"). That's a direct reversal of a gap the RFC author and maintainer both signed off on, not just an extension — this is the one I'd push back hardest on in review, since it wasn't discussed anywhere in the thread.

Spec needs to be changed if that is really the case.

  1. Interface shape changed from a Scope property to a family of marker interfaces: the RFC's interface had ActionScope Scope { get; } directly on IExecutableAction. The PR instead uses IExecutableGlobalAction, IExecutableSeriesAction, etc., and scope is inferred via typeof(T).IsAssignableFrom. This isn't called out in the "Differences" section even though it's a real structural departure — functionally it achieves the same routing goal, but a plugin author reading the RFC's published interface would write code that doesn't compile against this PR.

Either code or spec needs to be changed.

  1. ID derivation adds plugin ID as namespace: spec said UUIDv5 from "the action's fully-qualified class name" only. PR derives from class name plus plugin ID namespace. Arguably better (avoids cross-plugin collisions the RFC worried about elsewhere), but again, not what Gap 4/5 says.

looks like a good change spec needs to be updated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants