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/

================================================
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
================================================
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 = `
`
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 = `
`
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 = `'
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"
}
}