diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 4cf482aa3b..7e0aa60939 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -802,6 +802,10 @@ "description": "Automatically skip silences sections in songs", "name": "Skip Silences" }, + "skip-live-songs": { + "description": "Automatically tries to skip renditions of songs", + "name": "Skip Live Songs" + }, "sponsorblock": { "description": "Automatically Skips non-music parts like intro/outro or parts of music videos where the song isn't playing", "name": "SponsorBlock" diff --git a/src/plugins/skip-live-songs/index.ts b/src/plugins/skip-live-songs/index.ts new file mode 100644 index 0000000000..5c3e463e01 --- /dev/null +++ b/src/plugins/skip-live-songs/index.ts @@ -0,0 +1,88 @@ +import { t } from '@/i18n'; +import { createPlugin } from '@/utils'; + +import type { SongInfo } from '@/providers/song-info'; +import { nonStudioPatterns } from './patterns'; + +export default createPlugin({ + name: () => t('plugins.skip-live-songs.name'), + description: () => t('plugins.skip-live-songs.description'), + restartNeeded: false, + config: { + enabled: false, + }, + renderer: { + lastSkippedVideoId: '', + + _skipLiveHandler: undefined as unknown as + | ((songInfo: SongInfo) => void) + | undefined, + + start({ ipc }) { + console.debug('[Skip Live Songs] Renderer started'); + + const SELECTORS = [ + 'yt-icon-button.next-button', + '.next-button', + 'button[aria-label*="Next"]', + 'button[aria-label*="next"]', + '#player-bar-next-button', + 'ytmusic-player-bar .next-button', + '.player-bar .next-button', + ]; + + const handler = (songInfo: SongInfo) => { + const titleToCheck = songInfo.alternativeTitle || songInfo.title; + if (!titleToCheck) return; + + // Skip if we've already attempted this video id + if (songInfo.videoId === this.lastSkippedVideoId) return; + + const isNonStudio = nonStudioPatterns.some((pattern) => + pattern.test(titleToCheck), + ); + + if (!isNonStudio) return; // studio version — nothing to do + + // Mark as attempted so we don't loop repeatedly + this.lastSkippedVideoId = songInfo.videoId; + console.info( + `[Skip Live Songs] Skipping non-studio song: "${titleToCheck}" (id: ${songInfo.videoId})`, + ); + + let clicked = false; + for (const sel of SELECTORS) { + const button = document.querySelector(sel); + if (button) { + button.click(); + console.debug( + `[Skip Live Songs] Clicked next button using selector: ${sel}`, + ); + clicked = true; + break; + } + } + + if (!clicked) { + console.warn( + '[Skip Live Songs] Could not find next button with any configured selector', + ); + } + }; + + this._skipLiveHandler = handler; + ipc.on('peard:update-song-info', handler); + }, + + // Unregister the ipc handler on plugin stop to avoid duplicate listeners on hot reload + stop({ ipc }) { + if (this._skipLiveHandler) { + ipc.removeAllListeners('peard:update-song-info'); + this._skipLiveHandler = undefined; + console.debug( + '[Skip Live Songs] Renderer stopped and listeners removed', + ); + } + }, + }, +}); diff --git a/src/plugins/skip-live-songs/patterns.ts b/src/plugins/skip-live-songs/patterns.ts new file mode 100644 index 0000000000..d498243d87 --- /dev/null +++ b/src/plugins/skip-live-songs/patterns.ts @@ -0,0 +1,34 @@ +/** + * Patterns to detect non-studio recordings + * + * Add or modify patterns here to customize what gets skipped. + * All patterns are not case-sensitive. + */ + +export const nonStudioPatterns = [ + // "Live" in specific contexts (not as part of song title) + /[\(\[]live[\)\]]/i, // "(Live)" or "[Live]" in parentheses/brackets + /live\s+(at|from|in|on|with|@)/i, // "Live at", "Live from", "Live in", etc. + /live\s+with\b/i, // "Live with" (e.g. "Live with the SFSO") + /-\s*live\s*$/i, // "- Live" at the end of title + /:\s*live\s*$/i, // ": Live" at the end of title + // Concert/Performance indicators + /\b(concert|festival|tour)\b/i, // Concert, Festival, Tour + /\(.*?(concert|live performance|live recording).*?(19|20)\d{2}\)/i, // (Live 1985), (Concert 2024) + // Recording types + /\b(acoustic|unplugged|rehearsal|demo)\b/i, // Acoustic, Unplugged, Rehearsal, Demo + // Venues + /\b(arena|stadium|center|centre|hall)\b/i, // Arena, Stadium, Center, Hall + /\bmadison\s+square\s+garden\b/i, // Madison Square Garden + /day\s+on\s+the\s+green/i, // Day on the Green + // Famous venues/festivals + /\b(wembley|glastonbury|woodstock|coachella)\b/i, // Wembley, Glastonbury, Woodstock, Coachella + // Dates and locations + /\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+\d+/i, // "September 22", "August 31" + /\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/, // Date formats: 09/22/2024, 9-22-24 + /\b[A-Z][a-z]+,\s*[A-Z]{2}\b/, // Locations: "Oakland, CA", "London, UK" + /\b[A-Z][a-z]+\s+City\b/i, // Cities: "Mexico City", "New York City" + /\b(tokyo|paris|berlin|sydney)\b/i, // More cities + /\b(bbc|radio|session)\b/i, // Radio sessions +]; +