diff --git a/src/renderer/components/SponsorBlockSettings.vue b/src/renderer/components/SponsorBlockSettings.vue
index 7887080e4a9a1..65be1f0509ae5 100644
--- a/src/renderer/components/SponsorBlockSettings.vue
+++ b/src/renderer/components/SponsorBlockSettings.vue
@@ -20,6 +20,13 @@
:tooltip="$t('Tooltips.SponsorBlock Settings.UseDeArrowThumbnails')"
@change="handleUpdateUseDeArrowThumbnails"
/>
+
store.getters.getUseDeArrowTitles)
/** @type {import('vue').ComputedRef} */
const useDeArrowThumbnails = computed(() => store.getters.getUseDeArrowThumbnails)
+/** @type {import('vue').ComputedRef} */
+const deArrowCasualMode = computed(() => store.getters.getDeArrowCasualMode)
+
/** @type {import('vue').ComputedRef} */
const deArrowThumbnailGeneratorUrl = computed(() => store.getters.getDeArrowThumbnailGeneratorUrl)
@@ -130,6 +140,13 @@ function handleUpdateUseDeArrowThumbnails(value) {
store.dispatch('updateUseDeArrowThumbnails', value)
}
+/**
+ * @param {boolean} value
+ */
+function handleUpdateDeArrowCasualMode(value) {
+ store.dispatch('updateDeArrowCasualMode', value)
+}
+
/**
* @param {boolean} value
*/
diff --git a/src/renderer/components/ft-list-video/ft-list-video.js b/src/renderer/components/ft-list-video/ft-list-video.js
index 52c06fbf1dfa4..e05bec35404e7 100644
--- a/src/renderer/components/ft-list-video/ft-list-video.js
+++ b/src/renderer/components/ft-list-video/ft-list-video.js
@@ -535,6 +535,9 @@ export default defineComponent({
useDeArrowThumbnails: function () {
return this.$store.getters.getUseDeArrowThumbnails
},
+ deArrowCasualMode: function () {
+ return this.$store.getters.getDeArrowCasualMode
+ },
deArrowChangedContent: function () {
return (this.useDeArrowThumbnails && this.deArrowCache?.thumbnail) ||
(this.useDeArrowTitles && this.deArrowCache?.title &&
@@ -598,9 +601,13 @@ export default defineComponent({
const videoId = this.id
const data = await deArrowData(this.id)
const cacheData = { videoId, title: null, videoDuration: null, thumbnail: null, thumbnailTimestamp: null }
- if (Array.isArray(data?.titles) && data.titles.length > 0 && (data.titles[0].locked || data.titles[0].votes >= 0)) {
- // remove dearrow formatting markers https://github.com/ajayyy/DeArrow/blob/0da266485be902fe54259214c3cd7c942f2357c5/src/titles/titleFormatter.ts#L460
- cacheData.title = data.titles[0].title.replaceAll(/(^|\s)>(\S)/g, '$1$2').trim()
+ if (Array.isArray(data?.titles) && data.titles.length > 0) {
+ const selectedTitle = this.selectDeArrowTitle(data.titles, data.casualMode)
+ if (selectedTitle) {
+ // remove dearrow formatting markers
+ // https://github.com/ajayyy/DeArrow/blob/0da266485be902fe54259214c3cd7c942f2357c5/src/titles/titleFormatter.ts#L460
+ cacheData.title = selectedTitle.replaceAll(/(^|\s)>(\S)/g, '$1$2').trim()
+ }
}
if (Array.isArray(data?.thumbnails) && data.thumbnails.length > 0 && (data.thumbnails[0].locked || data.thumbnails[0].votes >= 0)) {
cacheData.thumbnailTimestamp = data.thumbnails.at(0).timestamp
@@ -621,6 +628,31 @@ export default defineComponent({
this.debounceGetDeArrowThumbnail()
}
},
+ selectDeArrowTitle: function(titles, casualMode) {
+ if (!Array.isArray(titles) || titles.length === 0) {
+ return null
+ }
+
+ if (casualMode) {
+ // Prefer a community-approved original title in casual mode.
+ const goodOriginal = titles.find(
+ (t) => t.original && (t.locked || t.votes >= 0)
+ )
+ if (goodOriginal) {
+ return goodOriginal.title
+ }
+
+ // No well-voted original — use the best custom title instead.
+ const bestCustom = titles.find(
+ (t) => !t.original && (t.locked || t.votes >= 0)
+ )
+ return bestCustom ? bestCustom.title : null
+ }
+
+ // Classic mode: use the first (highest-quality) title if it is trusted.
+ const best = titles[0]
+ return (best.locked || best.votes >= 0) ? best.title : null
+ },
toggleDeArrow() {
if (!this.deArrowChangedContent) {
return
diff --git a/src/renderer/helpers/sponsorblock.js b/src/renderer/helpers/sponsorblock.js
index 142974f342193..9b5874b7e5d86 100644
--- a/src/renderer/helpers/sponsorblock.js
+++ b/src/renderer/helpers/sponsorblock.js
@@ -2,10 +2,8 @@ import store from '../store/index'
async function getVideoHash(videoId) {
const videoIdBuffer = new TextEncoder().encode(videoId)
-
const hashBuffer = await crypto.subtle.digest('SHA-256', videoIdBuffer)
const hashArray = new Uint8Array(hashBuffer)
-
return hashArray[0].toString(16).padStart(2, '0') +
hashArray[1].toString(16).padStart(2, '0')
}
@@ -60,7 +58,14 @@ export async function sponsorBlockSkipSegments(videoId, categories) {
export async function deArrowData(videoId) {
const videoIdHashPrefix = await getVideoHash(videoId)
- const requestUrl = `${store.getters.getSponsorBlockUrl}/api/branding/${videoIdHashPrefix}`
+ const deArrowCasualMode = store.getters.getDeArrowCasualMode
+
+ // When casual mode is enabled we request all titles (including those with
+ // negative scores) so that upvoted original titles are also included in the
+ // response. The server already returns data in quality order, so we can
+ // still rely on position when picking the best non-original title as a
+ // fallback.
+ const requestUrl = `${store.getters.getSponsorBlockUrl}/api/branding/${videoIdHashPrefix}${deArrowCasualMode ? '?fetchAll=true' : ''}`
try {
const response = await fetch(requestUrl)
@@ -71,7 +76,15 @@ export async function deArrowData(videoId) {
}
const json = await response.json()
- return json[videoId] ?? undefined
+ const videoData = json[videoId] ?? undefined
+
+ if (videoData === undefined) {
+ return undefined
+ }
+
+ // Attach the current mode so the consumer can make the right selection
+ // decision without having to reach back into the store.
+ return { ...videoData, casualMode: deArrowCasualMode }
} catch (error) {
console.error('failed to fetch DeArrow data', requestUrl, error)
throw error
@@ -80,6 +93,7 @@ export async function deArrowData(videoId) {
export async function deArrowThumbnail(videoId, timestamp) {
let requestUrl = `${store.getters.getDeArrowThumbnailGeneratorUrl}/api/v1/getThumbnail?videoID=` + videoId
+
if (timestamp != null) {
requestUrl += `&time=${timestamp}`
}
diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js
index 4e04bbf396862..9b1f46c3545e0 100644
--- a/src/renderer/store/modules/settings.js
+++ b/src/renderer/store/modules/settings.js
@@ -297,6 +297,7 @@ const state = {
settingsPassword: '',
useDeArrowTitles: false,
useDeArrowThumbnails: false,
+ deArrowCasualMode: false,
deArrowThumbnailGeneratorUrl: 'https://dearrow-thumb.ajay.app',
// This makes the `favorites` playlist uses as quick bookmark target
// If the playlist is removed quick bookmark is disabled
diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml
index bab14105a4559..df8de85d5f8c1 100644
--- a/static/locales/en-US.yaml
+++ b/static/locales/en-US.yaml
@@ -659,6 +659,7 @@ Settings:
Notify when sponsor segment is skipped: Notify when sponsor segment is skipped
UseDeArrowTitles: Use DeArrow Video Titles
UseDeArrowThumbnails: Use DeArrow for thumbnails
+ DeArrowCasualMode: Use DeArrow Casual Mode
'DeArrow Thumbnail Generator API Url (Default is https://dearrow-thumb.ajay.app)': 'DeArrow Thumbnail Generator API Url (Default is https://dearrow-thumb.ajay.app)'
Skip Options:
Skip Option: Skip Option
@@ -1093,6 +1094,7 @@ Tooltips:
SponsorBlock Settings:
UseDeArrowTitles: Replace video titles with user-submitted titles from DeArrow.
UseDeArrowThumbnails: Replace video thumbnails with thumbnails from DeArrow.
+ DeArrowCasualMode: In Casual mode, original titles that the community has upvoted will be kept. Titles that are genuinely misleading will still be replaced.
# Toast Messages
Local API Error (Click to copy): Local API Error (Click to copy)