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

View File

@ -239,12 +239,24 @@
:tooltip="$t('Tooltips.Distraction Free Settings.Hide Channels')" :tooltip="$t('Tooltips.Distraction Free Settings.Hide Channels')"
:validate-tag-name="validateChannelId" :validate-tag-name="validateChannelId"
:find-tag-info="findChannelTagInfo" :find-tag-info="findChannelTagInfo"
:are-channel-tags="true"
@invalid-name="handleInvalidChannel" @invalid-name="handleInvalidChannel"
@error-find-tag-info="handleChannelAPIError" @error-find-tag-info="handleChannelAPIError"
@change="handleChannelsHidden" @change="handleChannelsHidden"
@already-exists="handleChannelsExists" @already-exists="handleChannelsExists"
/> />
</ft-flex-box> </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> </ft-settings-section>
</template> </template>

View File

@ -25,6 +25,10 @@ export default defineComponent({
appearance: { appearance: {
type: String, type: String,
required: true required: true
},
hideForbiddenTitles: {
type: Boolean,
default: true
} }
}, },
data: function () { data: function () {
@ -44,6 +48,15 @@ export default defineComponent({
computed: { computed: {
listType: function () { listType: function () {
return this.$store.getters.getListType 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 () { created: function () {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import FtInput from '../ft-input/ft-input.vue' import FtInput from '../ft-input/ft-input.vue'
import { showToast } from '../../helpers/utils'
export default defineComponent({ export default defineComponent({
name: 'FtInputTags', name: 'FtInputTags',
@ -7,6 +8,10 @@ export default defineComponent({
'ft-input': FtInput, 'ft-input': FtInput,
}, },
props: { props: {
areChannelTags: {
type: Boolean,
default: false
},
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false
@ -23,6 +28,10 @@ export default defineComponent({
type: String, type: String,
required: true required: true
}, },
minInputLength: {
type: Number,
default: 1
},
showActionButton: { showActionButton: {
type: Boolean, type: Boolean,
default: true default: true
@ -46,6 +55,30 @@ export default defineComponent({
}, },
methods: { methods: {
updateTags: async function (text, _e) { 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 // get text without spaces after last '/' in url, if any
const name = text.split('/').pop().trim() const name = text.split('/').pop().trim()
@ -73,6 +106,20 @@ export default defineComponent({
this.$refs.tagNameInput.handleClearTextClick() this.$refs.tagNameInput.handleClearTextClick()
}, },
removeTag: function (tag) { 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 // Remove tag from list
if (this.tagList.some((tmpTag) => tmpTag.name === tag.name)) { if (this.tagList.some((tmpTag) => tmpTag.name === tag.name)) {
const newList = this.tagList.filter((tmpTag) => tmpTag.name !== tag.name) const newList = this.tagList.filter((tmpTag) => tmpTag.name !== tag.name)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -215,7 +215,18 @@ export default defineComponent({
allowDashAv1Formats: function () { allowDashAv1Formats: function () {
return this.$store.getters.getAllowDashAv1Formats 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 () { isUserPlaylistRequested: function () {
return this.$route.query.playlistType === 'user' return this.$route.query.playlistType === 'user'
}, },
@ -1316,6 +1327,19 @@ export default defineComponent({
this.$refs.watchVideoPlaylist.playNextVideo() this.$refs.watchVideoPlaylist.playNextVideo()
return 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 const nextVideoInterval = this.defaultInterval
this.playNextTimeout = setTimeout(() => { this.playNextTimeout = setTimeout(() => {
const player = this.$refs.videoPlayer.player const player = this.$refs.videoPlayer.player
@ -1323,7 +1347,6 @@ export default defineComponent({
if (this.watchingPlaylist) { if (this.watchingPlaylist) {
this.$refs.watchVideoPlaylist.playNextVideo() this.$refs.watchVideoPlaylist.playNextVideo()
} else { } else {
const nextVideoId = this.recommendedVideos[0].videoId
this.$router.push({ this.$router.push({
path: `/watch/${nextVideoId}` path: `/watch/${nextVideoId}`
}) })
@ -1736,6 +1759,12 @@ export default defineComponent({
document.title = `${this.videoTitle} - FreeTube` 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() { updateLocalPlaylistLastPlayedAtSometimes() {
if (this.selectedUserPlaylist == null) { return } if (this.selectedUserPlaylist == null) { return }

View File

@ -53,6 +53,8 @@ Global:
Subscriber Count: 1 subscriber | {count} subscribers Subscriber Count: 1 subscriber | {count} subscribers
View Count: 1 view | {count} views View Count: 1 view | {count} views
Watching Count: 1 watching | {count} watching Watching Count: 1 watching | {count} watching
Input Tags:
Length Requirement: Tag must be at least {number} characters long
# Search Bar # Search Bar
Search / Go to URL: Search / Go to URL Search / Go to URL: Search / Go to URL
@ -465,6 +467,8 @@ Settings:
Hide Channel Shorts: Hide Channel Shorts Hide Channel Shorts: Hide Channel Shorts
Hide Channel Podcasts: Hide Channel Podcasts Hide Channel Podcasts: Hide Channel Podcasts
Hide Channel Releases: Hide Channel Releases 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 Videos: Hide Subscriptions Videos
Hide Subscriptions Shorts: Hide Subscriptions Shorts Hide Subscriptions Shorts: Hide Subscriptions Shorts
Hide Subscriptions Live: Hide Subscriptions Live Hide Subscriptions Live: Hide Subscriptions Live
@ -715,6 +719,7 @@ Channel:
votes: '{votes} votes' votes: '{votes} votes'
Reveal Answers: Reveal Answers Reveal Answers: Reveal Answers
Hide Answers: Hide Answers Hide Answers: Hide Answers
Video hidden by FreeTube: Video hidden by FreeTube
Video: Video:
Mark As Watched: Mark As Watched Mark As Watched: Mark As Watched
Remove From History: Remove From History 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. 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. 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 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: Subscription Settings:
Fetch Feeds from RSS: When enabled, FreeTube will use RSS instead of its default 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, 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} Screenshot Error: Screenshot failed. {error}
Channel Hidden: '{channel} added to channel filter' Channel Hidden: '{channel} added to channel filter'
Channel Unhidden: '{channel} removed from 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: Hashtag Hashtag: Hashtag