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
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name=viewport content="width=device-width, initial-scale=1.0, minimum-scale=0.5 maximum-scale=1.0">
<title>FreshTube</title>
<link rel="stylesheet" href="css/style.css?v=1.0">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<div id="title_bar">
<h1 id="main_title">FreshTube</h1>
<button id="settings_button" type="button" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-cog"></span></button>
</div>
<div id="error-box" class="alert alert-danger" role="alert"></div>
<form id="settings" class="well">
<div class="form-group">
<label for='apikey'>API Key</label>
<p class='quiet_text'>Use a YouTube API key from the <a href='https://console.developers.google.com'>Google Developer's Console</a>.</p>
<input type='text' id='apikey' class="form-control">
</div>
<div class="form-group">
<label for='video_urls'>Channels / Users</label>
<p class='quiet_text'>Paste YouTube URLs containing a username, handle or channel ID (not channel name), or podcast RSS feed URL. One URL per line.</p>
<textarea id="video_urls" rows=8 cols=50 class="form-control"></textarea>
</div>
<div class="form-group">
<p class='quiet_text'>(optional) Web link to a file containing the channel listing. Server must send appropriate <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS">CORS</a> header.</p>
<input type='text' id="weblink_url" class="form-control">
</div>
<div class="form-group">
<input type="checkbox" id='highlight_new' value=1 checked> highlight new videos (since last refresh)<br>
</div>
<div class="form-group">
<label for="hide_group">Hide Videos</label>
<div id="hide_group" class="input-group">
<input type="checkbox" id='hide_old_check'> older than <input id="hide_old_days" type="number" size=3 value="7" min=1 /> days<br>
<input type="checkbox" id='hide_future_check'> screening > <input id="hide_future_hours" type="number" size=3 value="2" min=1 /> hours in the future<br>
<input type="checkbox" id='hide_time_check'> < <input id="hide_time_mins" type="number" size=3 value="20" min=1 /> minutes in duration
</div>
</div>
<div class="form-group">
<label for="hide_group">Cache</label>
<div id="hide_group" class="input-group">
cache result for <input id="cache_result_mins" type="number" size=3 min=0 /> minutes (0 is no cache)
</div>
</div>
<div class="form-group">
<label for='vc_target'>Video Click Target</label>
<input type="url" id='vc_target' class="form-control" placeholder="https://videos.example.com/?video_url=%v">
<small class='form-text text-muted'>Used to override video click. Use macro '%v' to insert video URL into target URL (URL encoded).</small>
</div>
<button id='save_button' type="button" class="btn btn-success btn-lg">Save</button>
</form>
<div id='videos'></div>
<div id="footer">
<p class='quiet_text'><strong>Note:</strong> Your API key and other data are stored solely in your browser's local storage and are never shared with any third party.</p>
<div class='github-link'><a href='https://github.com/porjo/freshtube'><img src='github-mark.svg' width="25px"/></a></div>
</div>
<div class="overlay">
<div class="spinner">
<div class="lds-dual-ring"></div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/dayjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/plugin/relativeTime.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/plugin/duration.min.js"></script>
<script src="js/script.js"></script>
</body>
</html>
================================================
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 = `<div class="channel">
<div class="channel_title"><a href="${channelURL}" title="${line}" target="_blank">${channelTitle}</a></div>
<div class="video_list">`
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 || '<i>no videos found</i>'
videosOuter += '</div></div>'
$('#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 = `<div class="channel">
<div class="channel_title"><a href="${channelURL}/videos" target="_blank">${data.items[0].snippet.channelTitle}</a></div>
<div class="video_list">`
this.videos = ''
data.items.forEach(v => this.videoHTML(v))
videosOuter += this.videos || '<i>no videos found</i>'
videosOuter += '</div></div>'
$('#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('<div class="live"><span class="glyphicon glyphicon-record"></span> Live</div>')
}
}
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 = `<a class="video${rssHide ? ' would_hide' : ''}" data-id="${id}" href="${clickURL}" target="_blank">
<div class="video_thumb"><div class="video_sched"></div><img src="${v.snippet.thumbnails.medium.url}"></div>
<div class="video_title" title="${fullTitle}">${title}</div>`
if (duration) {
video += `<div class="video_duration">${duration.hours() > 0 ? duration.hours() + ':' : ''}${pad(duration.minutes(), 2)}:${pad(duration.seconds(), 2)}</div>`
} else if (rssLive) {
video += '<div class="video_duration"><div class="live"><span class="glyphicon glyphicon-record"></span> Live</div></div>'
} else {
video += '<div class="video_duration"></div>'
}
const publishedAt = dayjs(v.snippet.publishedAt)
video += `<div class="video_footer">
<div class="sponsorblock"><img src="sponsorblock.png"></div>
<div class="age" data-unix="${publishedAt.unix()}">${publishedAt.fromNow()}</div>
</div>`
if (this.configManager.config.lastRefresh && this.configManager.config.highlightNew &&
dayjs(this.configManager.config.lastRefresh).isBefore(v.snippet.publishedAt)) {
video += '<div class="ribbon"><span>New</span></div>'
}
video += '</a>'
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('<span class="show_hidden glyphicon glyphicon-eye-open"></span>')
}
})
}
}
// 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"
}
}
gitextract_1r5lk543/ ├── .eslintrc.js ├── .gitignore ├── README.md ├── css/ │ └── style.css ├── index.html ├── js/ │ └── script.js └── package.json
SYMBOL INDEX (33 symbols across 1 files)
FILE: js/script.js
class YouTubeAPIConstants (line 9) | class YouTubeAPIConstants {
class RegexPatterns (line 19) | class RegexPatterns {
class ConfigManager (line 28) | class ConfigManager {
method constructor (line 29) | constructor () {
method load (line 47) | async load () {
method save (line 62) | save () {
method updateUI (line 79) | updateUI () {
method handleCache (line 94) | async handleCache () {
class VideoManager (line 107) | class VideoManager {
method constructor (line 108) | constructor (configManager) {
method refresh (line 115) | async refresh () {
method getLines (line 129) | async getLines () {
method formatWeblinkURL (line 145) | formatWeblinkURL () {
method processLines (line 154) | async processLines (lines) {
method processLine (line 170) | async processLine (line) {
method fetchData (line 182) | async fetchData (url, json = true) {
method handleRSS (line 190) | async handleRSS (line) {
method processRSSItem (line 221) | processRSSItem (item, channelImageURL) {
method handleYouTubeChannel (line 245) | async handleYouTubeChannel (line) {
method handlePlaylist (line 285) | async handlePlaylist (channelURL, data) {
method getSponsorBlock (line 303) | async getSponsorBlock () {
method getDurations (line 320) | async getDurations () {
method processDuration (line 330) | processDuration (v) {
method getLiveBroadcasts (line 342) | async getLiveBroadcasts () {
method processLiveBroadcast (line 352) | processLiveBroadcast (v) {
method videoHTML (line 365) | videoHTML (v) {
method getClickURL (line 423) | getClickURL (url) {
class UIManager (line 430) | class UIManager {
method showError (line 431) | showError (message) {
method sortChannels (line 445) | sortChannels () {
method updateHiddenItemsStatus (line 473) | updateHiddenItemsStatus () {
function pad (line 507) | function pad (n, width, z = '0') {
function hmsToSecondsOnly (line 513) | function hmsToSecondsOnly (str) {
Condensed preview — 7 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (32K chars).
[
{
"path": ".eslintrc.js",
"chars": 397,
"preview": "module.exports = {\n env: {\n browser: true,\n es2021: true,\n jquery: true\n },\n extends: 'standard',\n override"
},
{
"path": ".gitignore",
"chars": 13,
"preview": "node_modules\n"
},
{
"path": "README.md",
"chars": 576,
"preview": "# FreshTube\n\nA tool to display latest videos from your favourite Youtube channels.\n\nFeatures:\n- flag new videos (since l"
},
{
"path": "css/style.css",
"chars": 5678,
"preview": "html, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, bi"
},
{
"path": "index.html",
"chars": 4014,
"preview": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\">\n\t\t<meta name=viewport content=\"width=device-width, in"
},
{
"path": "js/script.js",
"chars": 18407,
"preview": "/* eslint-disable no-unused-expressions */\n/* eslint-disable no-undef */\n'use strict'\n\n// Extend dayjs plugins\ndayjs.ext"
},
{
"path": "package.json",
"chars": 501,
"preview": "{\n \"name\": \"freshtube\",\n \"version\": \"1.0.0\",\n \"description\": \"A tool to display latest videos from your favourite You"
}
]
About this extraction
This page contains the full source code of the porjo/freshtube GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 7 files (28.9 KB), approximately 8.3k tokens, and a symbol index with 33 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.