Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(helix): add content classification and branded content management #811

Merged
merged 6 commits into from Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -2,60 +2,81 @@

import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.twitch4j.util.EnumUtil;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

import java.util.Map;

/**
* Content classification tags that indicate that a stream may not be suitable for certain viewers.
*
* @see <a href="https://safety.twitch.tv/s/article/Content-Classification-Guidelines?language=en_US">Official Guidelines</a>
*/
@RequiredArgsConstructor
public enum ContentClassification {

/**
* Excessive tobacco glorification or promotion, any marijuana consumption/use,
* legal drug and alcohol induced intoxication, discussions of illegal drugs.
*/
@JsonProperty("DrugsIntoxication")
DRUGS,
DRUGS("DrugsIntoxication"),

/**
* Participating in online or in-person gambling, poker or fantasy sports,
* that involve the exchange of real money.
*/
@JsonProperty("Gambling")
GAMBLING,
GAMBLING("Gambling"),

/**
* Games that are rated Mature or less suitable for a younger audience.
* <p>
* This tag is automatically applied based on the stream category.
*/
@JsonProperty("MatureGame")
MATURE_GAME,
MATURE_GAME("MatureGame"),

/**
* Prolonged, and repeated use of obscenities, profanities, and vulgarities,
* especially as a regular part of speech.
*/
@JsonProperty("ProfanityVulgarity")
PROFANITY,
PROFANITY("ProfanityVulgarity"),

/**
* Content that focuses on sexualized physical attributes and activities, sexual topics, or experiences.
*/
@JsonProperty("SexualThemes")
SEXUAL,
SEXUAL("SexualThemes"),

/**
* Simulations and/or depictions of realistic violence, gore, extreme injury, or death.
*/
@JsonProperty("ViolentGraphic")
VIOLENCE,
VIOLENCE("ViolentGraphic"),

/**
* The channel has a content classification label that is unrecognized by the library;
* Please file an issue on our GitHub repository.
*/
@JsonEnumDefaultValue
UNKNOWN;
UNKNOWN("Unknown");

private static final Map<String, ContentClassification> MAPPINGS = EnumUtil.buildMapping(values());

private final String twitchString;

@Override
public String toString() {
return this.twitchString;
}

@NotNull
@ApiStatus.Internal
public static ContentClassification parse(@NotNull String id) {
return MAPPINGS.getOrDefault(id, UNKNOWN);
}
}
Expand Up @@ -1363,6 +1363,23 @@ HystrixCommand<ClipList> getClips(
@Param("ended_at") Instant endedAt
);

/**
* Gets information about Twitch content classification labels.
*
* @param authToken App Access Token or User Access Token.
* @param locale Locale for the Content Classification Labels. Default: "en-US".
* Supported locales: "bg-BG", "cs-CZ", "da-DK", "da-DK", "de-DE", "el-GR", "en-GB", "en-US", "es-ES", "es-MX",
* "fi-FI", "fr-FR", "hu-HU", "it-IT", "ja-JP", "ko-KR", "nl-NL", "no-NO", "pl-PL", "pt-BT", "pt-PT",
* "ro-RO", "ru-RU", "sk-SK", "sv-SE", "th-TH", "tr-TR", "vi-VN", "zh-CN", "zh-TW"
* @return ContentClassificationList
*/
@RequestLine("GET /content_classification_labels?locale={locale}")
@Headers("Authorization: Bearer {token}")
HystrixCommand<ContentClassificationList> getContentClassificationLabels(
@Param("token") String authToken,
@Param("locale") String locale
);

/**
* Creates a URL where you can upload a manifest file and notify users that they have an entitlement
*
Expand Down
@@ -1,20 +1,32 @@
package com.github.twitch4j.helix.domain;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.github.twitch4j.eventsub.domain.ContentClassification;
import com.github.twitch4j.helix.interceptor.ContentClassificationStateListSerializer;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.Singular;
import lombok.With;
import lombok.experimental.Accessors;
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

@Data
@With
@Setter(AccessLevel.PRIVATE)
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ChannelInformation {

/**
Expand Down Expand Up @@ -76,4 +88,34 @@ public class ChannelInformation {
*/
private List<String> tags;

/**
* The CCLs applied to the channel.
*/
@Singular
@JsonSerialize(using = ContentClassificationStateListSerializer.class)
private Collection<ContentClassificationState> contentClassificationLabels;

/**
* Whether the channel has branded content.
*/
@Accessors(fluent = true)
@JsonProperty("is_branded_content")
private Boolean isBrandedContent;

/**
* Converts the {@code content_classification_labels} list from {@link com.github.twitch4j.helix.TwitchHelix#getChannelInformation(String, List)}
* into a list of {@link ContentClassificationState}, so that {@link ChannelInformation} can be passed to
* {@link com.github.twitch4j.helix.TwitchHelix#updateChannelInformation(String, String, ChannelInformation)},
* since the PATCH endpoint expects an array of objects (with {@code is_enabled} boolean flag)
* rather than an array of strings (that the GET endpoint yields).
*
* @param labels collection of {@link ContentClassification}'s
*/
@JsonProperty("content_classification_labels")
private void setContentClassificationLabels(Collection<ContentClassification> labels) {
if (labels == null) return;
this.contentClassificationLabels = new ArrayList<>(labels.size());
labels.forEach(label -> contentClassificationLabels.add(new ContentClassificationState(label, true)));
}

}
@@ -0,0 +1,38 @@
package com.github.twitch4j.helix.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.github.twitch4j.eventsub.domain.ContentClassification;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Data
@Setter(AccessLevel.PRIVATE)
@NoArgsConstructor
public class ContentClassificationInfo {

/**
* Unique identifier for the CCL.
*/
private String id;

/**
* Localized description of the CCL.
*/
private String description;

/**
* Localized name of the CCL.
*/
private String name;

/**
* @return {@link #getId()} parsed as {@link ContentClassification}.
*/
@JsonIgnore
public ContentClassification getLabel() {
return ContentClassification.parse(id);
}

}
@@ -0,0 +1,25 @@
package com.github.twitch4j.helix.domain;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.List;

/**
* Information about the available content classification labels.
*/
@Data
@Setter(AccessLevel.PRIVATE)
@NoArgsConstructor
public class ContentClassificationList {

/**
* The list of CCLs available.
*/
@JsonProperty("data")
private List<ContentClassificationInfo> labels;

}
@@ -0,0 +1,32 @@
package com.github.twitch4j.helix.domain;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.twitch4j.eventsub.domain.ContentClassification;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;
import org.jetbrains.annotations.NotNull;

@Data
@With
@Setter(AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class ContentClassificationState {

/**
* ID of the Content Classification Labels that must be added/removed from the channel.
*/
@NotNull
private ContentClassification id;

/**
* Whether the label should be enabled (true) or disabled for the channel.
*/
@JsonProperty("is_enabled")
private boolean isEnabled;

}
@@ -0,0 +1,36 @@
package com.github.twitch4j.helix.interceptor;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.github.twitch4j.eventsub.domain.ContentClassification;
import com.github.twitch4j.helix.domain.ChannelInformation;
import com.github.twitch4j.helix.domain.ContentClassificationState;
import org.jetbrains.annotations.ApiStatus;

import java.io.IOException;
import java.util.Collection;

/**
* Serializes {@code Collection<ContentClassificationState>} within {@link com.github.twitch4j.helix.domain.ChannelInformation}
* for {@link com.github.twitch4j.helix.TwitchHelix#updateChannelInformation(String, String, ChannelInformation)}
* where {@link ContentClassification#MATURE_GAME} is not included in {@link ChannelInformation#getContentClassificationLabels()}
* since this label is controlled by the game category (rather than the user).
*/
@ApiStatus.Internal
public class ContentClassificationStateListSerializer extends JsonSerializer<Collection<ContentClassificationState>> {
@Override
public void serialize(Collection<ContentClassificationState> value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value != null) {
gen.writeStartArray();
for (ContentClassificationState ccl : value) {
if (ccl == null) continue;
if (ccl.getId() == ContentClassification.MATURE_GAME) continue;
gen.writeObject(ccl);
}
gen.writeEndArray();
} else {
gen.writeNull();
}
}
}
@@ -0,0 +1,50 @@
package com.github.twitch4j.helix.domain;

import com.github.twitch4j.common.util.TypeConvert;
import com.github.twitch4j.eventsub.domain.ContentClassification;
import org.junit.jupiter.api.Test;

import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ChannelInformationTest {

@Test
void deserializeLabels() {
String json = "{\"content_classification_labels\":[\"Gambling\",\"DrugsIntoxication\",\"MatureGame\"],\"is_branded_content\":true}";
ChannelInformation info = TypeConvert.jsonToObject(json, ChannelInformation.class);
assertNotNull(info);
assertEquals(
Arrays.asList(
new ContentClassificationState(ContentClassification.GAMBLING, true),
new ContentClassificationState(ContentClassification.DRUGS, true),
new ContentClassificationState(ContentClassification.MATURE_GAME, true)
),
info.getContentClassificationLabels()
);
assertTrue(info.isBrandedContent());
}

@Test
void serializeLabels() {
ChannelInformation info = ChannelInformation.builder()
.contentClassificationLabel(new ContentClassificationState(ContentClassification.PROFANITY, true))
.contentClassificationLabel(new ContentClassificationState(ContentClassification.SEXUAL, false))
.build();
String expected = "{\"content_classification_labels\":[{\"id\":\"ProfanityVulgarity\",\"is_enabled\":true},{\"id\":\"SexualThemes\",\"is_enabled\":false}]}";
assertEquals(expected, TypeConvert.objectToJson(info));
}

@Test
void serializeWithoutMature() {
ChannelInformation info = ChannelInformation.builder()
.contentClassificationLabel(new ContentClassificationState(ContentClassification.MATURE_GAME, true))
.build();
String expected = "{\"content_classification_labels\":[]}";
assertEquals(expected, TypeConvert.objectToJson(info));
}

}