Repository: porjo/freshtube Branch: master Commit: 63e113ec320e Files: 7 Total size: 28.9 KB Directory structure: gitextract_1r5lk543/ ├── .eslintrc.js ├── .gitignore ├── README.md ├── css/ │ └── style.css ├── index.html ├── js/ │ └── script.js └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.js ================================================ module.exports = { env: { browser: true, es2021: true, jquery: true }, extends: 'standard', overrides: [ { env: { node: true }, files: [ '.eslintrc.{js,cjs}' ], parserOptions: { sourceType: 'script' } } ], parserOptions: { ecmaVersion: 'latest' }, rules: { }, globals: { dayjs: null } } ================================================ FILE: .gitignore ================================================ node_modules ================================================ FILE: README.md ================================================ # FreshTube A tool to display latest videos from your favourite Youtube channels. Features: - flag new videos (since last refresh) - fliter display by: age, duration, future publish time - modify the URL that is used when clicking on a video thumbnail (by default links to Youtube) - source Youtube channel list from a web link e.g. Nextcloud share link - supports RSS feed URLs aswell as Youtube Runs entirely in the browser, storing data in local storage. Use it here: https://porjo.github.io/freshtube/ ![Screenshot](https://porjo.github.io/freshtube/screenshot.jpg) ================================================ FILE: css/style.css ================================================ html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline; } /* HTML5 display-role reset for older browsers */ article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } @font-face { font-family: 'Pacifico'; font-style: normal; font-weight: 400; src: local('Pacifico Regular'), local('Pacifico-Regular'), url(Pacifico.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+ 2193, U+2212, U+2215, U+FEFF, U+FFFD; } .quiet_text { color: #777; } #title_bar { position: relative; } #main_title { font-family: 'Pacifico', cursive; margin-top: 20px; margin-bottom: 30px; font-size: 2em; font-weight: bold; color: #1f4a4d; display: inline-block; } #settings_button { position: absolute; right: 0; top: 50%; transform: translate(0, -50%); line-height: 1; font-size: 1.5em; } #settings { margin-bottom: 50px; display: none; } #error-box { display: none; } .channel { position: relative; margin-top: 20px; } /* @media (max-width: 800px) { .channel { padding: unset; padding-top: 5px; } } */ .video_list { display: flex; align-items: stretch; overflow-y: auto; padding: 5px; } .channel_title { font-size: 1.2em; font-weight: bold; display: flex; justify-content: space-between; align-items: flex-end; padding: 5px; background: linear-gradient(#ececec,#fff); border-radius: 5px; } #footer { border-top: 1px solid #bbb; padding: 10px; margin-top: 20px; font-size: 0.9em; display: flex; justify-content: space-between; } #showhide { float: right; } .video { position: relative; background: #eee; padding: 0; margin-right: 8px; width: 200px; min-width: 100px; font-size: 0.9em; display: flex; flex-direction: column; cursor: pointer; } .video_title { padding: 3px; background: #3c3c3c; color: #fff; flex-grow: 2; } .video_footer { padding: 3px; font-size: 0.9em; display: flex; justify-content: space-between; } .video_thumb img { width: 100%; } .video_duration { position: absolute; top: 0; right: 0; color: #fff; font-size: 0.9em; background-color: rgba(0,0,0,0.5); padding: 2px; } .video_sched { display: none; position: absolute; bottom: 0; right: 0; left: 0; text-align: center; color: #fff; font-size: 0.9em; font-weight: bold; background-color: rgba(0,67,225,0.75); padding: 2px; z-index: 1; } .close_channel, .show_hidden { opacity: 0.5; font-size: 1.5em; display: inline; margin: 0 5px; } .close_channel:hover, .show_hidden:hover { opacity: 1; cursor: pointer; } .would_hide { border: 2px dotted #0d05ff; display: none; } .grey-out { filter: grayscale(100%) opacity(50%); } .live { font-weight: bold; color: red; text-shadow: 0px 0px 2px black; font-size: 1.2em; } .sponsorblock { position: relative; } .sponsorblock img { position: absolute; height: 100%; display: none; } @media (max-width: 800px) { .channel_title a { display: inline-block; max-width: 70%; } .video { max-width: 150px; } } .github-link img { margin: 5px 10px; } /* http://www.cssportal.com/css-ribbon-generator/ */ .ribbon { position: absolute; left: -5px; top: -5px; z-index: 1; overflow: hidden; width: 75px; height: 75px; text-align: right; cursor: pointer; } .ribbon span { font-size: 10px; font-weight: bold; color: #FFF; text-transform: uppercase; text-align: center; line-height: 20px; transform: rotate(-45deg); -webkit-transform: rotate(-45deg); width: 100px; display: block; background: #79A70A; background: rgba(255,0,0,0.6); box-shadow: 0 3px 10px -5px rgba(0, 0, 0, 1); position: absolute; top: 19px; left: -21px; } .ribbon span::before { content: ""; position: absolute; left: 0px; top: 100%; z-index: -1; border-left: 3px solid #8F0808; border-right: 3px solid transparent; border-bottom: 3px solid transparent; border-top: 3px solid #8F0808; } .ribbon span::after { content: ""; position: absolute; right: 0px; top: 100%; z-index: -1; border-left: 3px solid transparent; border-right: 3px solid #8F0808; border-bottom: 3px solid transparent; border-top: 3px solid #8F0808; } /* spinner */ .lds-dual-ring, .lds-dual-ring:after { box-sizing: border-box; } .lds-dual-ring { display: inline-block; width: 80px; height: 80px; } .lds-dual-ring:after { content: " "; display: block; width: 64px; height: 64px; margin: 8px; border-radius: 50%; border: 6.4px solid currentColor; border-color: currentColor transparent currentColor transparent; animation: lds-dual-ring 1.2s linear infinite; } @keyframes lds-dual-ring { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .overlay { display: none; position: absolute; top: 0; left: 0; height: 100%; width: 100%; z-index: 10; background-color: rgba(255, 255, 255, 0.9) } .spinner { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } ================================================ FILE: index.html ================================================ FreshTube

FreshTube

Use a YouTube API key from the Google Developer's Console.

Paste YouTube URLs containing a username, handle or channel ID (not channel name), or podcast RSS feed URL. One URL per line.

(optional) Web link to a file containing the channel listing. Server must send appropriate CORS header.

highlight new videos (since last refresh)
older than days
screening > hours in the future
< minutes in duration
cache result for minutes (0 is no cache)
Used to override video click. Use macro '%v' to insert video URL into target URL (URL encoded).
================================================ FILE: js/script.js ================================================ /* eslint-disable no-unused-expressions */ /* eslint-disable no-undef */ 'use strict' // Extend dayjs plugins dayjs.extend(window.dayjs_plugin_relativeTime) dayjs.extend(window.dayjs_plugin_duration) class YouTubeAPIConstants { static CHANNEL_URL = 'https://www.googleapis.com/youtube/v3/channels?part=contentDetails' static PLAYLIST_URL = 'https://www.googleapis.com/youtube/v3/playlistItems?part=snippet' static DURATION_URL = 'https://www.googleapis.com/youtube/v3/videos?part=contentDetails' static LIVE_BROADCAST_URL = 'https://www.googleapis.com/youtube/v3/videos?part=snippet,liveStreamingDetails' static WATCH_URL = 'https://www.youtube.com/watch' static SPONSOR_BLOCK_URL = 'https://sponsor.ajay.app/api/skipSegments' static ITUNES_NAMESPACE = 'http://www.itunes.com/dtds/podcast-1.0.dtd' } class RegexPatterns { static CHANNEL = /youtube\.com\/channel\/([^/]+)\/?/ static CHANNEL_NAME = /youtube\.com\/c\/([^/]+)\/?/ static USER = /youtube\.com\/user\/([^/]+)\/?/ static HANDLE = /youtube\.com\/(@[^/]+)\/?/ static RSS = /(\/feed|rss|\.xml)/ static NEXTCLOUD = /s\/[a-zA-Z0-9]{15}(\/download\/?)?$/ } class ConfigManager { constructor () { this.config = { key: '', lastRefresh: null, highlightNew: true, hideOldCheck: true, hideOldDays: 1, hideFutureCheck: true, hideFutureHours: 2, hideTimeCheck: false, hideTimeMins: 20, videoClickTarget: null, weblinkURL: null, cacheResultMins: 15, cachedResult: null } } async load () { if (typeof Storage === 'undefined') return const storedConfig = localStorage.getItem('freshtube_config') if (storedConfig) { this.config = { ...this.config, ...JSON.parse(storedConfig) } } this.updateUI() if (this.config.lines || this.config.weblinkURL) { await this.handleCache() } this.save() } save () { this.config.lines = $('#video_urls').val().split('\n').filter(i => i) this.config.highlightNew = $('#highlight_new').is(':checked') this.config.lastRefresh = dayjs().toISOString() this.config.hideOldCheck = $('#hide_old_check').is(':checked') this.config.hideOldDays = Number($('#hide_old_days').val()) this.config.hideFutureCheck = $('#hide_future_check').is(':checked') this.config.hideFutureHours = Number($('#hide_future_hours').val()) this.config.hideTimeCheck = $('#hide_time_check').is(':checked') this.config.hideTimeMins = Number($('#hide_time_mins').val()) this.config.videoClickTarget = $('#vc_target').val() this.config.weblinkURL = $('#weblink_url').val() this.config.cacheResultMins = $('#cache_result_mins').val() localStorage.setItem('freshtube_config', JSON.stringify(this.config)) } updateUI () { $('#apikey').val(this.config.key) $('#highlight_new').prop('checked', this.config.highlightNew) $('#hide_old_check').prop('checked', this.config.hideOldCheck) $('#hide_old_days').val(this.config.hideOldDays) $('#hide_future_check').prop('checked', this.config.hideFutureCheck) $('#hide_future_hours').val(this.config.hideFutureHours) $('#hide_time_check').prop('checked', this.config.hideTimeCheck) $('#hide_time_mins').val(this.config.hideTimeMins) $('#cache_result_mins').val(this.config.cacheResultMins) $('#vc_target').val(this.config.videoClickTarget) $('#weblink_url').val(this.config.weblinkURL) $('#video_urls').val(this.config.lines?.join('\n') || '') } async handleCache () { console.time('cache') if (!this.config.cacheResultMins || !this.config.lastRefresh || dayjs().subtract(this.config.cacheResultMins, 'minutes').isAfter(this.config.lastRefresh)) { await videoManager.refresh() this.config.cachedResult = $('#videos').html() } else { $('#videos').html(this.config.cachedResult) } console.timeLog('cache', 'content loaded') } } class VideoManager { constructor (configManager) { this.configManager = configManager this.ytIds = [] this.videos = '' this.rssItemLimit = 20 } async refresh () { $('#error-box').hide() this.configManager.config.key = $('#apikey').val() if (!this.configManager.config.key) { uiManager.showError('API key cannot be empty') return } this.ytIds = [] const lines = await this.getLines() await this.processLines(lines) } async getLines () { let lines = $('#video_urls').val().split('\n') this.configManager.config.weblinkURL = $('#weblink_url').val() if (this.configManager.config.weblinkURL) { const url = this.formatWeblinkURL() try { const data = await this.fetchData(url, false) lines = data.split(/\n/).concat(lines) } catch (error) { uiManager.showError(`failed to fetch web link - check CORS headers: ${error.message}`) } } return Array.from(new Set(lines)) } formatWeblinkURL () { let url = this.configManager.config.weblinkURL const found = url.match(RegexPatterns.NEXTCLOUD) if (found && !found[1]) { url += '/download' } return url } async processLines (lines) { $('#videos').html('') $('.overlay').show() const promises = lines.map(line => this.processLine(line)) await Promise.all(promises) await this.getDurations() uiManager.sortChannels() uiManager.updateHiddenItemsStatus() await Promise.all([this.getSponsorBlock(), this.getLiveBroadcasts()]) $('.overlay').hide() } async processLine (line) { if (!line.trim() || line.match(/^#/)) return $('#settings').slideUp() if (RegexPatterns.RSS.test(line)) { return this.handleRSS(line) } else { return this.handleYouTubeChannel(line) } } async fetchData (url, json = true) { const response = await fetch(url) if (!response.ok) { throw new Error(`network response was not ok: ${response.statusText}`) } return json ? response.json() : response.text() } async handleRSS (line) { try { const data = await this.fetchData(line, false) const parser = new DOMParser() const doc = parser.parseFromString(data, 'text/xml') const channel = doc.querySelector('channel') const channelTitle = channel.querySelector('title')?.textContent || '' const channelURL = channel.querySelector('link')?.textContent || '' const channelImageURL = channel.querySelector('image url')?.textContent || '' let videosOuter = `
${channelTitle}
` this.videos = '' const rssVids = Array.from(channel.querySelectorAll('item')) .slice(0, this.rssItemLimit) .map(item => this.processRSSItem(item, channelImageURL)) rssVids.forEach(v => this.videoHTML(v)) videosOuter += this.videos || 'no videos found' videosOuter += '
' $('#videos').append(videosOuter) } catch (error) { uiManager.showError(error.message) } } processRSSItem (item, channelImageURL) { const imageElements = item.getElementsByTagNameNS(YouTubeAPIConstants.ITUNES_NAMESPACE, 'image') // item image overrides any channel image const itemImageURL = imageElements.length ? imageElements[0].getAttribute('href') : channelImageURL const enclosureElement = item.querySelector('enclosure') const watchURL = enclosureElement?.getAttribute('url') || item.querySelector('link')?.textContent || '' const durationElements = item.getElementsByTagNameNS(YouTubeAPIConstants.ITUNES_NAMESPACE, 'duration') const duration = durationElements.length ? durationElements[0].textContent : '' return { snippet: { title: item.querySelector('title')?.textContent || '', resourceId: { videoId: item.querySelector('guid')?.textContent || '' }, thumbnails: { medium: { url: itemImageURL } }, publishedAt: item.querySelector('pubDate')?.textContent || '', watchURL, duration } } } async handleYouTubeChannel (line) { let url = `${YouTubeAPIConstants.CHANNEL_URL}&key=${this.configManager.config.key}` let channelURL = 'https://www.youtube.com/' const chanMatches = line.match(RegexPatterns.CHANNEL) const chanNameMatches = line.match(RegexPatterns.CHANNEL_NAME) const userMatches = line.match(RegexPatterns.USER) const handleMatches = line.match(RegexPatterns.HANDLE) if (chanMatches?.[1]) { channelURL += `channel/${chanMatches[1]}` url += `&id=${chanMatches[1]}` } else if (userMatches?.[1]) { channelURL += `user/${userMatches[1]}` url += `&forUsername=${userMatches[1]}` } else if (handleMatches?.[1]) { channelURL += handleMatches[1] url += `&forHandle=${handleMatches[1]}` } else if (chanNameMatches?.[1]) { // try channel name as handle channelURL += chanNameMatches[1] url += `&forHandle=${chanNameMatches[1]}` } else { const id = line.trim() url += id.length === 24 ? `&id=${id}` : `&forUsername=${id}` channelURL += id.length === 24 ? `channel/${id}` : `user/${id}` } try { const data = await this.fetchData(url) if (data?.items) { const playlistID = data.items[0].contentDetails.relatedPlaylists.uploads const playlistUrl = `${YouTubeAPIConstants.PLAYLIST_URL}&key=${this.configManager.config.key}&playlistId=${playlistID}` const playlistData = await this.fetchData(playlistUrl) await this.handlePlaylist(channelURL, playlistData) } } catch (error) { uiManager.showError(error.message) } } async handlePlaylist (channelURL, data) { if (!data?.items?.length) return data.items.sort((a, b) => dayjs(a.snippet.publishedAt).isBefore(b.snippet.publishedAt) ? 1 : -1) let videosOuter = `
${data.items[0].snippet.channelTitle}
` this.videos = '' data.items.forEach(v => this.videoHTML(v)) videosOuter += this.videos || 'no videos found' videosOuter += '
' $('#videos').append(videosOuter) } async getSponsorBlock () { const promises = this.ytIds.filter(id => id.length === 11).map(async videoId => { try { const response = await fetch(`${YouTubeAPIConstants.SPONSOR_BLOCK_URL}?videoID=${videoId}`) if (response.ok) { const data = await response.json() if (Array.isArray(data) && data.length) { $(`.video[data-id="${videoId}"] .sponsorblock > img`).show() } } } catch (error) { uiManager.showError(`failed to fetch SponsorBlock: ${error.message}`) } }) return Promise.all(promises) } async getDurations () { const url = `${YouTubeAPIConstants.DURATION_URL}&key=${this.configManager.config.key}&id=${this.ytIds.join(',')}` try { const data = await this.fetchData(url) data.items.forEach(v => this.processDuration(v)) } catch (error) { uiManager.showError(`failed to fetch durations: ${error.message}`) } } processDuration (v) { const duration = dayjs.duration(v.contentDetails.duration) const durationStr = `${duration.hours() > 0 ? duration.hours() + ':' : ''}${pad(duration.minutes(), 2)}:${pad(duration.seconds(), 2)}` $(`.video[data-id="${v.id}"] .video_duration`).text(durationStr) const minutes = duration.as('minutes') if (this.configManager.config.hideTimeCheck && minutes > 0 && minutes < this.configManager.config.hideTimeMins) { $(`.video[data-id="${v.id}"]`).addClass('would_hide') } } async getLiveBroadcasts () { const url = `${YouTubeAPIConstants.LIVE_BROADCAST_URL}&key=${this.configManager.config.key}&id=${this.ytIds.join(',')}` try { const data = await this.fetchData(url) data.items.forEach(v => this.processLiveBroadcast(v)) } catch (error) { uiManager.showError(`failed to fetch live broadcasts: ${error.message}`) } } processLiveBroadcast (v) { if (v.snippet.liveBroadcastContent === 'upcoming') { if (this.configManager.config.hideFutureCheck && dayjs().add(this.configManager.config.hideFutureHours, 'hours').isBefore(v.liveStreamingDetails.scheduledStartTime)) { $(`.video[data-id="${v.id}"]`).addClass('would_hide') } $(`.video[data-id="${v.id}"] .video_sched`).text(dayjs(v.liveStreamingDetails.scheduledStartTime).fromNow()).show() $(`.video[data-id="${v.id}"] .video_thumb img`).addClass('grey-out') } else if (v.snippet.liveBroadcastContent === 'live') { $(`.video[data-id="${v.id}"] .video_duration`).html('
Live
') } } videoHTML (v) { if (this.configManager.config.hideOldCheck && dayjs().subtract(this.configManager.config.hideOldDays, 'days').isAfter(v.snippet.publishedAt)) { return } const id = v.snippet.resourceId.videoId let rssHide = false let rssLive = false let duration if ('duration' in v.snippet) { if (!v.snippet.duration) { rssLive = true } else { duration = v.snippet.duration.includes(':') ? dayjs.duration(hmsToSecondsOnly(v.snippet.duration), 'seconds') : dayjs.duration(v.snippet.duration, 'seconds') if (this.configManager.config.hideTimeCheck && duration.as('minutes') < this.configManager.config.hideTimeMins) { rssHide = true } } } else { this.ytIds.push(id) } const fullTitle = v.snippet.title const title = fullTitle.length > 50 ? fullTitle.substring(0, 50) + '...' : fullTitle const watch = 'watchURL' in v.snippet ? v.snippet.watchURL : `${YouTubeAPIConstants.WATCH_URL}?v=${id}` const clickURL = this.getClickURL(watch) let video = `
${title}
` if (duration) { video += `
${duration.hours() > 0 ? duration.hours() + ':' : ''}${pad(duration.minutes(), 2)}:${pad(duration.seconds(), 2)}
` } else if (rssLive) { video += '
Live
' } else { video += '
' } const publishedAt = dayjs(v.snippet.publishedAt) video += `` if (this.configManager.config.lastRefresh && this.configManager.config.highlightNew && dayjs(this.configManager.config.lastRefresh).isBefore(v.snippet.publishedAt)) { video += '
New
' } video += '
' this.videos += video } getClickURL (url) { return this.configManager.config.videoClickTarget ? this.configManager.config.videoClickTarget.replace('%v', encodeURIComponent(url)) : url } } class UIManager { showError (message) { $('.overlay').hide() window.scrollTo({ top: 0, behavior: 'smooth' }) let errMsg = typeof message === 'string' ? message : 'Unknown error occurred' if (typeof message === 'object' && 'responseJSON' in message) { errMsg = message.responseJSON.error.errors .map(val => `${val.reason}, ${val.message}`) .join('; ') } $('#error-box').text(`Error: ${errMsg}`).show() } sortChannels () { const list = $('#videos')[0] const listItems = Array.from(list.children) listItems.sort((a, b) => { const aAges = a.querySelectorAll('.video:not(.would_hide) .age') const bAges = b.querySelectorAll('.video:not(.would_hide) .age') if (!aAges.length && !bAges.length) return 0 if (aAges.length && !bAges.length) return -1 if (bAges.length && !aAges.length) return 1 if (aAges.length && bAges.length) { const aUnix = aAges[0].getAttribute('data-unix') const bUnix = bAges[0].getAttribute('data-unix') return aUnix > bUnix ? -1 : 1 } const aListNew = a.querySelectorAll('.video:not(.would_hide) .ribbon') const bListNew = b.querySelectorAll('.video:not(.would_hide) .ribbon') if (aListNew.length > bListNew.length) return -1 const aList = a.querySelectorAll('.video:not(.would_hide)') const bList = b.querySelectorAll('.video:not(.would_hide)') return aList.length > bList.length ? -1 : 1 }).forEach(node => list.appendChild(node)) } updateHiddenItemsStatus () { $('.channel').each(function () { const hasHidden = $(this).find('.video_list .video').is(':hidden') if (hasHidden) { $(this).find('.channel_title') .append('') } }) } } // Initialize const configManager = new ConfigManager() const uiManager = new UIManager() const videoManager = new VideoManager(configManager) // Event listeners $(document).ready(() => { configManager.load() if (!configManager.config.key) $('#settings').slideDown() $('body').on('click', '.show_hidden', function () { $(this).closest('.channel').find('.would_hide').slideToggle(200) }) $('#settings_button').click(() => $('#settings').slideToggle(200)) $('#save_button').click(async () => { configManager.save() await videoManager.refresh() }) }) // Utility functions function pad (n, width, z = '0') { n = n + '' return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n } // Credit: https://stackoverflow.com/a/9640417/202311 function hmsToSecondsOnly (str) { const p = str.split(':') let s = 0 let m = 1 while (p.length > 0) { s += m * parseInt(p.pop(), 10) m *= 60 } return s } ================================================ FILE: package.json ================================================ { "name": "freshtube", "version": "1.0.0", "description": "A tool to display latest videos from your favourite Youtube channels.", "main": ".eslintrc.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "eslint": "^8.56.0", "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-n": "^16.6.2", "eslint-plugin-promise": "^6.1.1" } }