mirror of https://github.com/FreeTubeApp/FreeTube
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:
parent
ea3db79cf4
commit
64e3f32f78
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -30,6 +30,10 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hideForbiddenTitles: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
listType: function () {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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']"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
/>
|
||||
<ft-community-post
|
||||
v-else-if="finalDataType === 'community'"
|
||||
:hide-forbidden-titles="hideForbiddenTitles"
|
||||
:appearance="appearance"
|
||||
:data="data"
|
||||
/>
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
callback: onVisibilityChanged,
|
||||
once: true,
|
||||
}"
|
||||
:style="{ display }"
|
||||
>
|
||||
<ft-list-video
|
||||
v-if="visible"
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -206,6 +206,7 @@ const state = {
|
|||
hideComments: false,
|
||||
hideFeaturedChannels: false,
|
||||
channelsHidden: '[]',
|
||||
forbiddenTitles: '[]',
|
||||
hideVideoDescription: false,
|
||||
hideLiveChat: false,
|
||||
hideLiveStreams: false,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
:data="activeData"
|
||||
:data-type="'playlist'"
|
||||
:use-channels-hidden-preference="false"
|
||||
:hide-forbidden-titles="false"
|
||||
/>
|
||||
<ft-flex-box
|
||||
v-if="showLoadMoreButton"
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue