Video title filter / blacklist (#4202)

* Implement hiding of videos with user-inputted text

* Implement ft-input minInputLength

* Enable for playlists

* Enable feature on channel pages

The premise for this change is that users would not want to see that forbidden content anywhere, as opposed to hidden channels, where you're clearly on a channel page to see things from that channel.

* Fix 'Play Next Video' playing forbiddenTitle videos and hidden channel videos

* Fix issue of hidden recommended videos taking up vertical space

* Rename variables to better match non-video-specific function, and remove blocks from History and videos in playlists

* Fix to respect hideForbiddenTitles value

* Modify label

* Clarify restriction affecting original titles

* Add toast for entered input of length below min input length

* Add toast for element already exists

* Update to not clear if duplicate tag is entered for Hide Forbidden feature
This commit is contained in:
Jason 2024-01-22 22:59:46 +00:00 committed by GitHub
parent ea3db79cf4
commit 64e3f32f78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 193 additions and 18 deletions

View File

@ -120,13 +120,16 @@ export default defineComponent({
return ch
})
},
forbiddenTitles: function() {
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
hideSubscriptionsLiveTooltip: function () {
return this.$t('Tooltips.Distraction Free Settings.Hide Subscriptions Live', {
appWideSetting: this.$t('Settings.Distraction Free Settings.Hide Live Streams'),
subsection: this.$t('Settings.Distraction Free Settings.Sections.General'),
settingsSection: this.$t('Settings.Distraction Free Settings.Distraction Free Settings')
})
}
},
},
mounted: function () {
this.verifyChannelsHidden()
@ -148,6 +151,9 @@ export default defineComponent({
handleChannelsHidden: function (value) {
this.updateChannelsHidden(JSON.stringify(value))
},
handleForbiddenTitles: function (value) {
this.updateForbiddenTitles(JSON.stringify(value))
},
handleChannelsExists: function () {
showToast(this.$t('Settings.Distraction Free Settings.Hide Channels Already Exists'))
},
@ -206,6 +212,7 @@ export default defineComponent({
'updateHideSharingActions',
'updateHideChapters',
'updateChannelsHidden',
'updateForbiddenTitles',
'updateShowDistractionFreeTitles',
'updateHideFeaturedChannels',
'updateHideChannelShorts',

View File

@ -239,12 +239,24 @@
:tooltip="$t('Tooltips.Distraction Free Settings.Hide Channels')"
:validate-tag-name="validateChannelId"
:find-tag-info="findChannelTagInfo"
:are-channel-tags="true"
@invalid-name="handleInvalidChannel"
@error-find-tag-info="handleChannelAPIError"
@change="handleChannelsHidden"
@already-exists="handleChannelsExists"
/>
</ft-flex-box>
<ft-flex-box>
<ft-input-tags
:label="$t('Settings.Distraction Free Settings.Hide Videos and Playlists Containing Text')"
:tag-name-placeholder="$t('Settings.Distraction Free Settings.Hide Videos and Playlists Containing Text Placeholder')"
:show-action-button="true"
:tag-list="forbiddenTitles"
:min-input-length="3"
:tooltip="$t('Tooltips.Distraction Free Settings.Hide Videos and Playlists Containing Text')"
@change="handleForbiddenTitles"
/>
</ft-flex-box>
</ft-settings-section>
</template>

View File

@ -25,6 +25,10 @@ export default defineComponent({
appearance: {
type: String,
required: true
},
hideForbiddenTitles: {
type: Boolean,
default: true
}
},
data: function () {
@ -44,6 +48,15 @@ export default defineComponent({
computed: {
listType: function () {
return this.$store.getters.getListType
},
forbiddenTitles() {
if (!this.hideForbiddenTitles) { return [] }
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
hideVideo() {
return this.forbiddenTitles.some((text) => this.data.postContent.content.title?.toLowerCase().includes(text.toLowerCase()))
}
},
created: function () {

View File

@ -13,6 +13,12 @@
box-sizing: border-box;
}
.hiddenVideo {
font-style: italic;
opacity: 0.85;
text-align: center;
}
.communityImage {
block-size: 100%;
inline-size: 100%;

View File

@ -90,9 +90,16 @@
v-if="type === 'video'"
>
<ft-list-video
v-if="!hideVideo"
:data="data.postContent.content"
appearance=""
/>
<p
v-else
class="hiddenVideo"
>
{{ '[' + $t('Channel.Community.Video hidden by FreeTube') + ']' }}
</p>
</div>
<div
v-if="type === 'poll' || type === 'quiz'"

View File

@ -30,6 +30,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
hideForbiddenTitles: {
type: Boolean,
default: true
}
},
computed: {
listType: function () {

View File

@ -12,6 +12,7 @@
:layout="displayValue"
:show-video-with-last-viewed-playlist="showVideoWithLastViewedPlaylist"
:use-channels-hidden-preference="useChannelsHiddenPreference"
:hide-forbidden-titles="hideForbiddenTitles"
/>
</ft-auto-grid>
</template>

View File

@ -1,5 +1,6 @@
import { defineComponent } from 'vue'
import FtInput from '../ft-input/ft-input.vue'
import { showToast } from '../../helpers/utils'
export default defineComponent({
name: 'FtInputTags',
@ -7,6 +8,10 @@ export default defineComponent({
'ft-input': FtInput,
},
props: {
areChannelTags: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
@ -23,6 +28,10 @@ export default defineComponent({
type: String,
required: true
},
minInputLength: {
type: Number,
default: 1
},
showActionButton: {
type: Boolean,
default: true
@ -46,6 +55,30 @@ export default defineComponent({
},
methods: {
updateTags: async function (text, _e) {
if (this.areChannelTags) {
await this.updateChannelTags(text, _e)
return
}
// add tag and update tag list
const trimmedText = text.trim()
if (this.minInputLength > trimmedText.length) {
showToast(this.$tc('Trimmed input must be at least N characters long', this.minInputLength, { length: this.minInputLength }))
return
}
if (this.tagList.includes(trimmedText)) {
showToast(this.$t('Tag already exists', { tagName: trimmedText }))
return
}
const newList = this.tagList.slice(0)
newList.push(trimmedText)
this.$emit('change', newList)
// clear input box
this.$refs.tagNameInput.handleClearTextClick()
},
updateChannelTags: async function (text, _e) {
// get text without spaces after last '/' in url, if any
const name = text.split('/').pop().trim()
@ -73,6 +106,20 @@ export default defineComponent({
this.$refs.tagNameInput.handleClearTextClick()
},
removeTag: function (tag) {
if (this.areChannelTags) {
this.removeChannelTag(tag)
return
}
// Remove tag from list
const tagName = tag.trim()
if (this.tagList.includes(tagName)) {
const newList = this.tagList.slice(0)
const index = newList.indexOf(tagName)
newList.splice(index, 1)
this.$emit('change', newList)
}
},
removeChannelTag: function (tag) {
// Remove tag from list
if (this.tagList.some((tmpTag) => tmpTag.name === tag.name)) {
const newList = this.tagList.filter((tmpTag) => tmpTag.name !== tag.name)

View File

@ -13,6 +13,7 @@
:disabled="disabled"
:placeholder="tagNamePlaceholder"
:label="label"
:min-input-length="minInputLength"
:show-label="true"
:tooltip="tooltip"
:show-action-button="showActionButton"
@ -26,18 +27,21 @@
v-for="tag in tagList"
:key="tag.id"
>
<router-link
v-if="tag.icon"
:to="tag.iconHref ?? ''"
class="tag-icon-link"
>
<img
:src="tag.icon"
alt=""
class="tag-icon"
<template v-if="areChannelTags">
<router-link
v-if="tag.icon"
:to="tag.iconHref ?? ''"
class="tag-icon-link"
>
</router-link>
<span>{{ (tag.preferredName) ? tag.preferredName : tag.name }}</span>
<img
:src="tag.icon"
alt=""
class="tag-icon"
>
</router-link>
<span>{{ (tag.preferredName) ? tag.preferredName : tag.name }}</span>
</template>
<span v-else>{{ tag }}</span>
<font-awesome-icon
v-if="!disabled"
:icon="['fas', 'fa-times']"

View File

@ -1,6 +1,7 @@
import { defineComponent } from 'vue'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { mapActions } from 'vuex'
import FtTooltip from '../ft-tooltip/ft-tooltip.vue'
import { isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings'
export default defineComponent({
@ -143,7 +144,9 @@ export default defineComponent({
methods: {
handleClick: function (e) {
// No action if no input text
if (!this.inputDataPresent) { return }
if (!this.inputDataPresent) {
return
}
this.searchState.showOptions = false
this.searchState.selectedOption = -1

View File

@ -43,6 +43,10 @@ export default defineComponent({
type: Boolean,
default: true,
},
hideForbiddenTitles: {
type: Boolean,
default: true
},
},
data: function () {
return {
@ -65,6 +69,10 @@ export default defineComponent({
return ch
})
},
forbiddenTitles: function() {
if (!this.hideForbiddenTitles) { return [] }
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
hideUpcomingPremieres: function () {
return this.$store.getters.getHideUpcomingPremieres
},
@ -102,6 +110,9 @@ export default defineComponent({
// hide videos by author
return false
}
if (this.forbiddenTitles.some((text) => this.data.title?.toLowerCase().includes(text.toLowerCase()))) {
return false
}
} else if (dataType === 'channel') {
const attrsToCheck = [
// Local API
@ -117,6 +128,9 @@ export default defineComponent({
return false
}
} else if (dataType === 'playlist') {
if (this.forbiddenTitles.some((text) => this.data.title?.toLowerCase().includes(text.toLowerCase()))) {
return false
}
const attrsToCheck = [
// Local API
data.channelId,

View File

@ -31,6 +31,7 @@
/>
<ft-community-post
v-else-if="finalDataType === 'community'"
:hide-forbidden-titles="hideForbiddenTitles"
:appearance="appearance"
:data="data"
/>

View File

@ -75,10 +75,15 @@ export default defineComponent({
type: Boolean,
default: false,
},
hideForbiddenTitles: {
type: Boolean,
default: true
}
},
data: function () {
return {
visible: false
visible: false,
display: 'block'
}
},
computed: {
@ -95,9 +100,15 @@ export default defineComponent({
})
},
forbiddenTitles() {
if (!this.hideForbiddenTitles) { return [] }
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
shouldBeVisible() {
return !(this.channelsHidden.some(ch => ch.name === this.data.authorId) ||
this.channelsHidden.some(ch => ch.name === this.data.author))
this.channelsHidden.some(ch => ch.name === this.data.author) ||
this.forbiddenTitles.some((text) => this.data.title?.toLowerCase().includes(text.toLowerCase())))
}
},
created() {
@ -107,6 +118,8 @@ export default defineComponent({
onVisibilityChanged: function (visible) {
if (visible && this.shouldBeVisible) {
this.visible = visible
} else if (visible) {
this.display = 'none'
}
}
}

View File

@ -4,6 +4,7 @@
callback: onVisibilityChanged,
once: true,
}"
:style="{ display }"
>
<ft-list-video
v-if="visible"

View File

@ -144,6 +144,7 @@
:playlist-reverse="reversePlaylist"
:playlist-shuffle="shuffleEnabled"
:playlist-loop="loopEnabled"
:hide-forbidden-titles="false"
appearance="watchPlaylistItem"
force-list-type="list"
:initial-visible-state="index < (currentVideoIndexZeroBased + 4) && index > (currentVideoIndexZeroBased - 4)"

View File

@ -206,6 +206,7 @@ const state = {
hideComments: false,
hideFeaturedChannels: false,
channelsHidden: '[]',
forbiddenTitles: '[]',
hideVideoDescription: false,
hideLiveChat: false,
hideLiveStreams: false,

View File

@ -37,6 +37,7 @@
:data="activeData"
:show-video-with-last-viewed-playlist="true"
:use-channels-hidden-preference="false"
:hide-forbidden-titles="false"
/>
<ft-flex-box
v-if="showLoadMoreButton"

View File

@ -66,6 +66,7 @@
:can-move-video-up="index > 0"
:can-move-video-down="index < playlistItems.length - 1"
:can-remove-from-playlist="true"
:hide-forbidden-titles="false"
@move-video-up="moveVideoUp(item.videoId, item.playlistItemId)"
@move-video-down="moveVideoDown(item.videoId, item.playlistItemId)"
@remove-from-playlist="removeVideoFromPlaylist(item.videoId, item.playlistItemId)"

View File

@ -57,6 +57,7 @@
:data="activeData"
:data-type="'playlist'"
:use-channels-hidden-preference="false"
:hide-forbidden-titles="false"
/>
<ft-flex-box
v-if="showLoadMoreButton"

View File

@ -215,7 +215,18 @@ export default defineComponent({
allowDashAv1Formats: function () {
return this.$store.getters.getAllowDashAv1Formats
},
channelsHidden() {
return JSON.parse(this.$store.getters.getChannelsHidden).map((ch) => {
// Legacy support
if (typeof ch === 'string') {
return { name: ch, preferredName: '', icon: '' }
}
return ch
})
},
forbiddenTitles() {
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
isUserPlaylistRequested: function () {
return this.$route.query.playlistType === 'user'
},
@ -1316,6 +1327,19 @@ export default defineComponent({
this.$refs.watchVideoPlaylist.playNextVideo()
return
}
let nextVideoId = null
if (!this.watchingPlaylist) {
const forbiddenTitles = this.forbiddenTitles
const channelsHidden = this.channelsHidden
nextVideoId = this.recommendedVideos.find((video) =>
!this.isHiddenVideo(forbiddenTitles, channelsHidden, video)
)?.videoId
if (!nextVideoId) {
return
}
}
const nextVideoInterval = this.defaultInterval
this.playNextTimeout = setTimeout(() => {
const player = this.$refs.videoPlayer.player
@ -1323,7 +1347,6 @@ export default defineComponent({
if (this.watchingPlaylist) {
this.$refs.watchVideoPlaylist.playNextVideo()
} else {
const nextVideoId = this.recommendedVideos[0].videoId
this.$router.push({
path: `/watch/${nextVideoId}`
})
@ -1736,6 +1759,12 @@ export default defineComponent({
document.title = `${this.videoTitle} - FreeTube`
},
isHiddenVideo: function (forbiddenTitles, channelsHidden, video) {
return channelsHidden.some(ch => ch.name === video.authorId) ||
channelsHidden.some(ch => ch.name === video.author) ||
forbiddenTitles.some((text) => video.title?.toLowerCase().includes(text.toLowerCase()))
},
updateLocalPlaylistLastPlayedAtSometimes() {
if (this.selectedUserPlaylist == null) { return }

View File

@ -53,6 +53,8 @@ Global:
Subscriber Count: 1 subscriber | {count} subscribers
View Count: 1 view | {count} views
Watching Count: 1 watching | {count} watching
Input Tags:
Length Requirement: Tag must be at least {number} characters long
# Search Bar
Search / Go to URL: Search / Go to URL
@ -465,6 +467,8 @@ Settings:
Hide Channel Shorts: Hide Channel Shorts
Hide Channel Podcasts: Hide Channel Podcasts
Hide Channel Releases: Hide Channel Releases
Hide Videos and Playlists Containing Text: Hide Videos and Playlists Containing Text
Hide Videos and Playlists Containing Text Placeholder: Word, Word Fragment, or Phrase
Hide Subscriptions Videos: Hide Subscriptions Videos
Hide Subscriptions Shorts: Hide Subscriptions Shorts
Hide Subscriptions Live: Hide Subscriptions Live
@ -715,6 +719,7 @@ Channel:
votes: '{votes} votes'
Reveal Answers: Reveal Answers
Hide Answers: Hide Answers
Video hidden by FreeTube: Video hidden by FreeTube
Video:
Mark As Watched: Mark As Watched
Remove From History: Remove From History
@ -983,6 +988,7 @@ Tooltips:
Hide Channels: Enter a channel ID to hide all videos, playlists and the channel itself from appearing in search, trending, most popular and recommended.
The channel ID entered must be a complete match and is case sensitive.
Hide Subscriptions Live: 'This setting is overridden by the app-wide "{appWideSetting}" setting, in the "{subsection}" section of the "{settingsSection}"'
Hide Videos and Playlists Containing Text: Enter a word, word fragment, or phrase (case insensitive) to hide all videos & playlists whose original titles contain it throughout all of FreeTube, excluding only History, Your Playlists, and videos inside of playlists.
Subscription Settings:
Fetch Feeds from RSS: When enabled, FreeTube will use RSS instead of its default
method for grabbing your subscription feed. RSS is faster and prevents IP blocking,
@ -1038,6 +1044,8 @@ Screenshot Success: Saved screenshot as "{filePath}"
Screenshot Error: Screenshot failed. {error}
Channel Hidden: '{channel} added to channel filter'
Channel Unhidden: '{channel} removed from channel filter'
Trimmed input must be at least N characters long: Trimmed input must be at least 1 character long | Trimmed input must be at least {length} characters long
Tag already exists: '"{tagName}" tag already exists'
Hashtag:
Hashtag: Hashtag