From 64e3f32f7879bd99798674144c4bb3a1431518db Mon Sep 17 00:00:00 2001 From: Jason <84899178+jasonhenriquez@users.noreply.github.com> Date: Mon, 22 Jan 2024 22:59:46 +0000 Subject: [PATCH] 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 --- .../distraction-settings.js | 9 +++- .../distraction-settings.vue | 12 +++++ .../ft-community-post/ft-community-post.js | 13 +++++ .../ft-community-post/ft-community-post.scss | 6 +++ .../ft-community-post/ft-community-post.vue | 7 +++ .../ft-element-list/ft-element-list.js | 4 ++ .../ft-element-list/ft-element-list.vue | 1 + .../components/ft-input-tags/ft-input-tags.js | 47 +++++++++++++++++++ .../ft-input-tags/ft-input-tags.vue | 26 +++++----- src/renderer/components/ft-input/ft-input.js | 7 ++- .../ft-list-lazy-wrapper.js | 14 ++++++ .../ft-list-lazy-wrapper.vue | 1 + .../ft-list-video-lazy/ft-list-video-lazy.js | 17 ++++++- .../ft-list-video-lazy/ft-list-video-lazy.vue | 1 + .../watch-video-playlist.vue | 1 + src/renderer/store/modules/settings.js | 1 + src/renderer/views/History/History.vue | 1 + src/renderer/views/Playlist/Playlist.vue | 1 + .../views/UserPlaylists/UserPlaylists.vue | 1 + src/renderer/views/Watch/Watch.js | 33 ++++++++++++- static/locales/en-US.yaml | 8 ++++ 21 files changed, 193 insertions(+), 18 deletions(-) diff --git a/src/renderer/components/distraction-settings/distraction-settings.js b/src/renderer/components/distraction-settings/distraction-settings.js index c704206642e4f..a540b94bde48b 100644 --- a/src/renderer/components/distraction-settings/distraction-settings.js +++ b/src/renderer/components/distraction-settings/distraction-settings.js @@ -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', diff --git a/src/renderer/components/distraction-settings/distraction-settings.vue b/src/renderer/components/distraction-settings/distraction-settings.vue index eed6640160ff6..6420d447c8916 100644 --- a/src/renderer/components/distraction-settings/distraction-settings.vue +++ b/src/renderer/components/distraction-settings/distraction-settings.vue @@ -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" /> + + + diff --git a/src/renderer/components/ft-community-post/ft-community-post.js b/src/renderer/components/ft-community-post/ft-community-post.js index f3a229c184a58..350eccc102020 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.js +++ b/src/renderer/components/ft-community-post/ft-community-post.js @@ -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 () { diff --git a/src/renderer/components/ft-community-post/ft-community-post.scss b/src/renderer/components/ft-community-post/ft-community-post.scss index b55876fe91a41..64a618cfe9f4e 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.scss +++ b/src/renderer/components/ft-community-post/ft-community-post.scss @@ -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%; diff --git a/src/renderer/components/ft-community-post/ft-community-post.vue b/src/renderer/components/ft-community-post/ft-community-post.vue index 34379ce287d82..d5701b660fe24 100644 --- a/src/renderer/components/ft-community-post/ft-community-post.vue +++ b/src/renderer/components/ft-community-post/ft-community-post.vue @@ -90,9 +90,16 @@ v-if="type === 'video'" > +

+ {{ '[' + $t('Channel.Community.Video hidden by FreeTube') + ']' }} +

diff --git a/src/renderer/components/ft-input-tags/ft-input-tags.js b/src/renderer/components/ft-input-tags/ft-input-tags.js index 0c04f021be1f1..e139db3b76b9e 100644 --- a/src/renderer/components/ft-input-tags/ft-input-tags.js +++ b/src/renderer/components/ft-input-tags/ft-input-tags.js @@ -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) diff --git a/src/renderer/components/ft-input-tags/ft-input-tags.vue b/src/renderer/components/ft-input-tags/ft-input-tags.vue index 6b236e937ca77..58464b5476cd4 100644 --- a/src/renderer/components/ft-input-tags/ft-input-tags.vue +++ b/src/renderer/components/ft-input-tags/ft-input-tags.vue @@ -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" > - - + - - {{ (tag.preferredName) ? tag.preferredName : tag.name }} + + + {{ (tag.preferredName) ? tag.preferredName : tag.name }} + + {{ tag }} 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, diff --git a/src/renderer/components/ft-list-lazy-wrapper/ft-list-lazy-wrapper.vue b/src/renderer/components/ft-list-lazy-wrapper/ft-list-lazy-wrapper.vue index 1cace8c779682..654d6bbde5aa8 100644 --- a/src/renderer/components/ft-list-lazy-wrapper/ft-list-lazy-wrapper.vue +++ b/src/renderer/components/ft-list-lazy-wrapper/ft-list-lazy-wrapper.vue @@ -31,6 +31,7 @@ /> diff --git a/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js b/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js index ef5a044b27d44..5fb6ba9f8604f 100644 --- a/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js +++ b/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js @@ -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' } } } diff --git a/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.vue b/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.vue index 9af01de8af16a..69abd6a3a9221 100644 --- a/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.vue +++ b/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.vue @@ -4,6 +4,7 @@ callback: onVisibilityChanged, once: true, }" + :style="{ display }" > { + // 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 } diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index b18dc697596d6..b8fc52b81a3b5 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -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