mirror of
https://github.com/grimsi/gameyfin.git
synced 2026-02-06 11:27:07 +00:00
Add javadoc to plugin-api
Configure plugin-api build to publish to Maven Central
This commit is contained in:
parent
c84c6a1d56
commit
8d9ce92c51
35
.github/workflows/pluginapi-release.yml
vendored
Normal file
35
.github/workflows/pluginapi-release.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: Plugin-API Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Decrypt and import GPG key
|
||||
run: |
|
||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
|
||||
- name: Build and deploy with JReleaser
|
||||
run: ./gradlew jreleaserFullRelease
|
||||
env:
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
MAVENCENTRAL_USERNAME: ${{ secrets.MAVENCENTRAL_USERNAME }}
|
||||
MAVENCENTRAL_TOKEN: ${{ secrets.MAVENCENTRAL_TOKEN }}
|
||||
JRELEASER_GITHUB_TOKEN: ${{ GITHUB_TOKEN }}
|
||||
25
.run/Publish Plugin API.run.xml
Normal file
25
.run/Publish Plugin API.run.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Publish Plugin API" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="--no-configuration-cache" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value=":plugin-api:clean" />
|
||||
<option value=":plugin-api:publishAndReleaseToMavenCentral" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@ -1,23 +1,49 @@
|
||||
import com.vanniktech.maven.publish.SonatypeHost
|
||||
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
`maven-publish`
|
||||
`java-library`
|
||||
id("com.vanniktech.maven.publish") version "0.32.0"
|
||||
}
|
||||
|
||||
group = "org.gameyfin"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// PF4J (shared)
|
||||
api("org.pf4j:pf4j:${rootProject.extra["pf4jVersion"]}")
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
|
||||
signAllPublications()
|
||||
|
||||
coordinates(project.group.toString(), project.name, project.version.toString())
|
||||
|
||||
pom {
|
||||
name = "Gameyfin Plugin API"
|
||||
description =
|
||||
"The Gameyfin Plugin API provides the necessary interfaces and classes to create plugins for Gameyfin."
|
||||
url = "https://gameyfin.org/"
|
||||
|
||||
licenses {
|
||||
license {
|
||||
name = "GNU Affero General Public License v3.0"
|
||||
url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
|
||||
}
|
||||
}
|
||||
|
||||
developers {
|
||||
developer {
|
||||
id = "grimsi"
|
||||
name = "Simon Grimme"
|
||||
url = "https://github.com/grimsi"
|
||||
}
|
||||
}
|
||||
|
||||
scm {
|
||||
url = "https://github.com/gameyfin/gameyfin"
|
||||
connection = "scm:git:git://github.com/gameyfin/gameyfin.git"
|
||||
developerConnection = "scm:git:ssh://git@github.com/gameyfin/gameyfin.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,24 @@ package org.gameyfin.pluginapi.core.config
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Alias for a list of ConfigMetadata objects for plugin configuration.
|
||||
*/
|
||||
typealias PluginConfigMetadata = List<ConfigMetadata<*>>
|
||||
|
||||
/**
|
||||
* Represents metadata for a configuration property.
|
||||
*
|
||||
* @param T The type of the configuration value, must be Serializable.
|
||||
* @property key The unique (in the scope of the plugin) key for the configuration property.
|
||||
* @property type The class type of the configuration value.
|
||||
* @property label A human-readable label for the configuration property.
|
||||
* @property description A short description of the configuration property.
|
||||
* @property default The default value for the configuration property, if any.
|
||||
* @property isSecret Whether the configuration value is secret (e.g., password). Affects how the value is displayed in the UI.
|
||||
* @property isRequired Whether the configuration property is required.
|
||||
* @property allowedValues The allowed values for the configuration property, if applicable (e.g., for enums). Will be populated automatically if the type is an enum.
|
||||
*/
|
||||
data class ConfigMetadata<T : Serializable>(
|
||||
val key: String,
|
||||
val type: Class<T>,
|
||||
@ -13,6 +29,9 @@ data class ConfigMetadata<T : Serializable>(
|
||||
val isSecret: Boolean = false,
|
||||
val isRequired: Boolean = true,
|
||||
) {
|
||||
/**
|
||||
* List of allowed values for the configuration property, if the type is an enum.
|
||||
*/
|
||||
var allowedValues: List<T>? = null
|
||||
|
||||
init {
|
||||
|
||||
@ -2,14 +2,52 @@ package org.gameyfin.pluginapi.core.config
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Interface for classes that can be configured using plugin configuration metadata.
|
||||
* Provides methods for loading, validating, and accessing configuration values.
|
||||
*/
|
||||
interface Configurable {
|
||||
/**
|
||||
* The metadata describing the configuration options for this configurable instance.
|
||||
*/
|
||||
val configMetadata: PluginConfigMetadata
|
||||
|
||||
/**
|
||||
* Loads configuration values from the provided map.
|
||||
*
|
||||
* @param config A map of configuration keys to their string values (values are nullable).
|
||||
*/
|
||||
fun loadConfig(config: Map<String, String?>)
|
||||
|
||||
/**
|
||||
* Validates the current configuration state.
|
||||
*
|
||||
* @return The result of the configuration validation.
|
||||
*/
|
||||
fun validateConfig(): PluginConfigValidationResult
|
||||
|
||||
/**
|
||||
* Validates the provided configuration map.
|
||||
*
|
||||
* @param config A map of configuration keys to their string values (nullable).
|
||||
* @return The result of the configuration validation.
|
||||
*/
|
||||
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
|
||||
|
||||
/**
|
||||
* Retrieves a required configuration value by key.
|
||||
*
|
||||
* @param key The configuration key.
|
||||
* @return The configuration value of type T.
|
||||
* @throws Exception if the key is missing or the value is invalid.
|
||||
*/
|
||||
fun <T : Serializable> config(key: String): T
|
||||
|
||||
/**
|
||||
* Retrieves an optional configuration value by key.
|
||||
*
|
||||
* @param key The configuration key.
|
||||
* @return The configuration value of type T, or null if not present.
|
||||
*/
|
||||
fun <T : Serializable> optionalConfig(key: String): T?
|
||||
}
|
||||
@ -1,3 +1,11 @@
|
||||
package org.gameyfin.pluginapi.core.config
|
||||
|
||||
/**
|
||||
* Exception thrown when there is an error in plugin configuration.
|
||||
*
|
||||
* This exception is used to indicate problems such as missing, invalid, or malformed configuration values
|
||||
* when loading or validating plugin configuration.
|
||||
*
|
||||
* @param message The detail message describing the configuration error.
|
||||
*/
|
||||
class PluginConfigError(message: String) : RuntimeException(message)
|
||||
@ -1,22 +1,50 @@
|
||||
package org.gameyfin.pluginapi.core.config
|
||||
|
||||
/**
|
||||
* Represents the result of plugin configuration validation.
|
||||
*
|
||||
* @property result The type of validation result (VALID, INVALID, UNKNOWN).
|
||||
* @property errors A map of configuration keys to error messages, present if validation failed.
|
||||
*/
|
||||
data class PluginConfigValidationResult(
|
||||
val result: PluginConfigValidationResultType,
|
||||
val errors: Map<String, String>? = null
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* A valid configuration result with no errors.
|
||||
*/
|
||||
val VALID = PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
|
||||
|
||||
/**
|
||||
* An unknown configuration validation result.
|
||||
*/
|
||||
val UNKNOWN = PluginConfigValidationResult(PluginConfigValidationResultType.UNKNWOWN)
|
||||
|
||||
/**
|
||||
* Creates an invalid configuration result with the specified errors.
|
||||
*
|
||||
* @param errors A map of configuration keys to error messages.
|
||||
* @return An invalid PluginConfigValidationResult instance.
|
||||
*/
|
||||
fun INVALID(errors: Map<String, String>): PluginConfigValidationResult {
|
||||
return PluginConfigValidationResult(PluginConfigValidationResultType.INVALID, errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the configuration is valid.
|
||||
*
|
||||
* @return True if the result is VALID, false otherwise.
|
||||
*/
|
||||
fun isValid(): Boolean {
|
||||
return result == PluginConfigValidationResultType.VALID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing the possible types of plugin configuration validation results.
|
||||
*/
|
||||
enum class PluginConfigValidationResultType {
|
||||
VALID,
|
||||
INVALID,
|
||||
|
||||
@ -7,17 +7,47 @@ import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||
import org.pf4j.PluginWrapper
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Abstract base class for Gameyfin plugins that support configuration.
|
||||
*
|
||||
* This class implements the [Configurable] interface and provides default logic for loading,
|
||||
* validating, and accessing plugin configuration values using metadata.
|
||||
*
|
||||
* @constructor Creates a configurable Gameyfin plugin with the given [PluginWrapper].
|
||||
* @param wrapper The plugin wrapper provided by the Gameyfin application.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable {
|
||||
|
||||
/**
|
||||
* The current configuration map, where keys are configuration property names and values are their string representations.
|
||||
*/
|
||||
private var config: Map<String, String?> = emptyMap()
|
||||
|
||||
/**
|
||||
* Loads configuration values from the provided map.
|
||||
*
|
||||
* @param config A map of configuration keys to their string values (nullable).
|
||||
*/
|
||||
override fun loadConfig(config: Map<String, String?>) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the current configuration state.
|
||||
*
|
||||
* @return The result of the configuration validation.
|
||||
*/
|
||||
override fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
|
||||
|
||||
/**
|
||||
* Validates the provided configuration map.
|
||||
* The validation covers basic checks such as required fields, type casting, and allowed values.
|
||||
* It's recommended to override this method in subclasses to implement additional validation logic specific to the plugin.
|
||||
*
|
||||
* @param config A map of configuration keys to their string values (nullable).
|
||||
* @return The result of the configuration validation.
|
||||
*/
|
||||
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
@ -43,6 +73,13 @@ abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlug
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an optional configuration value by key.
|
||||
*
|
||||
* @param key The configuration key.
|
||||
* @return The configuration value of type T, or null if not present or invalid.
|
||||
* @throws PluginConfigError if the value cannot be cast to the expected type.
|
||||
*/
|
||||
override fun <T : Serializable> optionalConfig(key: String): T? {
|
||||
val meta = resolveMetadata(key)
|
||||
val value = resolveValue(key)
|
||||
@ -55,6 +92,13 @@ abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlug
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a required configuration value by key.
|
||||
*
|
||||
* @param key The configuration key.
|
||||
* @return The configuration value of type T.
|
||||
* @throws PluginConfigError if the key is missing or the value is invalid.
|
||||
*/
|
||||
override fun <T : Serializable> config(key: String): T {
|
||||
val value = optionalConfig<T>(key)
|
||||
if (value == null) {
|
||||
@ -63,6 +107,16 @@ abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlug
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts a configuration value to the expected type defined in the metadata.
|
||||
*
|
||||
* Handles enums, common primitive types, and attempts to use valueOf/parse methods via reflection for custom types.
|
||||
*
|
||||
* @param meta The configuration metadata describing the expected type.
|
||||
* @param value The value to cast.
|
||||
* @return The cast value, or throws PluginConfigError if casting fails.
|
||||
* @throws PluginConfigError if the value cannot be cast to the expected type.
|
||||
*/
|
||||
private fun castConfigValue(meta: ConfigMetadata<*>, value: Any): Any? {
|
||||
val expectedType = meta.type
|
||||
|
||||
@ -106,11 +160,25 @@ abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlug
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configuration metadata for a given key.
|
||||
*
|
||||
* @param key The configuration key to look up.
|
||||
* @return The corresponding ConfigMetadata instance.
|
||||
* @throws PluginConfigError if the key is unknown.
|
||||
*/
|
||||
private fun resolveMetadata(key: String): ConfigMetadata<*> {
|
||||
return configMetadata.find { it.key == key }
|
||||
?: throw PluginConfigError("Unknown configuration key: $key")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the value for a configuration key, optionally using an override map.
|
||||
*
|
||||
* @param key The configuration key to resolve.
|
||||
* @param configOverride An optional map to override the current configuration.
|
||||
* @return The resolved value, or the default from metadata if not present.
|
||||
*/
|
||||
private fun resolveValue(key: String, configOverride: Map<String, Serializable?>? = null): Serializable? {
|
||||
val meta = resolveMetadata(key)
|
||||
val conf = configOverride ?: config
|
||||
|
||||
@ -3,21 +3,48 @@ package org.gameyfin.pluginapi.core.wrapper
|
||||
import org.pf4j.Plugin
|
||||
import org.pf4j.PluginWrapper
|
||||
|
||||
/**
|
||||
* Abstract base class for all Gameyfin plugins.
|
||||
*
|
||||
* This class extends the PF4J [Plugin] class and provides utility methods for plugin logo management.
|
||||
* It also maintains a static reference to the current plugin instance.
|
||||
*
|
||||
* @constructor Creates a Gameyfin plugin with the given [PluginWrapper].
|
||||
* @param wrapper The plugin wrapper provided by the Gameyfin application.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The base name of the logo file (without extension).
|
||||
*/
|
||||
const val LOGO_FILE_NAME: String = "logo"
|
||||
|
||||
/**
|
||||
* Supported logo file formats.
|
||||
*/
|
||||
val SUPPORTED_LOGO_FORMATS: List<String> = listOf("png", "jpg", "jpeg", "gif", "svg", "webp")
|
||||
|
||||
/**
|
||||
* Reference to the current plugin instance.
|
||||
*/
|
||||
lateinit var plugin: GameyfinPlugin
|
||||
private set
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the plugin and sets the static plugin reference.
|
||||
*/
|
||||
init {
|
||||
plugin = this
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the plugin contains a logo file in any supported format.
|
||||
*
|
||||
* @return True if a logo file is found, false otherwise.
|
||||
*/
|
||||
fun hasLogo(): Boolean {
|
||||
for (format in SUPPORTED_LOGO_FORMATS) {
|
||||
val resourcePath = "$LOGO_FILE_NAME.$format"
|
||||
@ -30,6 +57,11 @@ abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the logo file as a byte array, if present.
|
||||
*
|
||||
* @return The logo file as a byte array, or null if not found.
|
||||
*/
|
||||
fun getLogo(): ByteArray? {
|
||||
for (format in SUPPORTED_LOGO_FORMATS) {
|
||||
val resourcePath = "$LOGO_FILE_NAME.$format"
|
||||
|
||||
@ -2,15 +2,29 @@ package org.gameyfin.pluginapi.download
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Represents a downloadable resource, which can be either a file or a link.
|
||||
*/
|
||||
sealed interface Download
|
||||
|
||||
/**
|
||||
* Represents a file-based download.
|
||||
*
|
||||
* @property data The input stream containing the file data.
|
||||
* @property fileExtension The file extension (e.g., "zip", "png"), or null if none.
|
||||
* @property size The size of the file in bytes, or null if unknown.
|
||||
*/
|
||||
data class FileDownload(
|
||||
val data: InputStream,
|
||||
val fileExtension: String? = null,
|
||||
val size: Long? = null
|
||||
) : Download
|
||||
|
||||
/**
|
||||
* Represents a link-based download.
|
||||
*
|
||||
* @property url The URL to the downloadable resource.
|
||||
*/
|
||||
data class LinkDownload(
|
||||
val url: String
|
||||
) : Download
|
||||
|
||||
|
||||
@ -3,7 +3,19 @@ package org.gameyfin.pluginapi.download
|
||||
import org.pf4j.ExtensionPoint
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Extension point for providing downloadable resources.
|
||||
*
|
||||
* Implementations of this interface are responsible for handling download requests for specific paths.
|
||||
* This is typically used to allow plugins to serve files or links for download.
|
||||
*/
|
||||
interface DownloadProvider : ExtensionPoint {
|
||||
|
||||
/**
|
||||
* Downloads a resource for the given path.
|
||||
*
|
||||
* @param path The path to the resource to download.
|
||||
* @return A [Download] representing the downloadable resource (file or link).
|
||||
*/
|
||||
fun download(path: Path): Download
|
||||
}
|
||||
@ -3,6 +3,26 @@ package org.gameyfin.pluginapi.gamemetadata
|
||||
import java.net.URI
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents metadata for a game, including identifiers, descriptive information, media, ratings, and categorization.
|
||||
*
|
||||
* @property originalId The unique identifier for the game from the original source.
|
||||
* @property title The title of the game.
|
||||
* @property description A description of the game, or null if not available.
|
||||
* @property coverUrl The URI to the game's cover image, or null if not available.
|
||||
* @property release The release date and time of the game, or null if not available.
|
||||
* @property userRating The user rating for the game, or null if not available.
|
||||
* @property criticRating The critic rating for the game, or null if not available.
|
||||
* @property developedBy The set of developer names, or null if not available.
|
||||
* @property publishedBy The set of publisher names, or null if not available.
|
||||
* @property genres The set of genres associated with the game, or null if not available.
|
||||
* @property themes The set of themes associated with the game, or null if not available.
|
||||
* @property keywords The set of keywords associated with the game, or null if not available.
|
||||
* @property screenshotUrls The set of URIs to screenshots, or null if not available.
|
||||
* @property videoUrls The set of URIs to videos, or null if not available.
|
||||
* @property features The set of features associated with the game, or null if not available.
|
||||
* @property perspectives The set of player perspectives, or null if not available.
|
||||
*/
|
||||
data class GameMetadata(
|
||||
val originalId: String,
|
||||
val title: String,
|
||||
@ -22,6 +42,9 @@ data class GameMetadata(
|
||||
val perspectives: Set<PlayerPerspective>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Enum representing the genre of a game.
|
||||
*/
|
||||
enum class Genre {
|
||||
UNKNOWN,
|
||||
ACTION,
|
||||
@ -51,6 +74,9 @@ enum class Genre {
|
||||
QUIZ_TRIVIA
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing the theme of a game.
|
||||
*/
|
||||
enum class Theme {
|
||||
UNKNOWN,
|
||||
ACTION,
|
||||
|
||||
@ -2,8 +2,27 @@ package org.gameyfin.pluginapi.gamemetadata
|
||||
|
||||
import org.pf4j.ExtensionPoint
|
||||
|
||||
/**
|
||||
* Extension point for providing game metadata.
|
||||
*
|
||||
* Implementations of this interface are responsible for fetching game metadata by title or by unique identifier.
|
||||
* This is typically used to allow plugins to provide metadata for games from various sources.
|
||||
*/
|
||||
interface GameMetadataProvider : ExtensionPoint {
|
||||
/**
|
||||
* Fetches a list of game metadata entries matching the given game title.
|
||||
*
|
||||
* @param gameTitle The title of the game to search for.
|
||||
* @param maxResults The maximum number of results to return. Defaults to 1.
|
||||
* @return A list of [GameMetadata] objects matching the title, or an empty list if none found.
|
||||
*/
|
||||
fun fetchByTitle(gameTitle: String, maxResults: Int = 1): List<GameMetadata>
|
||||
|
||||
/**
|
||||
* Fetches game metadata by its unique identifier.
|
||||
*
|
||||
* @param id The unique identifier of the game.
|
||||
* @return The [GameMetadata] for the given id, or null if not found.
|
||||
*/
|
||||
fun fetchById(id: String): GameMetadata?
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user