[
  {
    "path": ".eslintrc.js",
    "content": "module.exports = {\n  env: {\n    browser: true,\n    es2021: true,\n    jquery: true\n  },\n  extends: 'standard',\n  overrides: [\n    {\n      env: {\n        node: true\n      },\n      files: [\n        '.eslintrc.{js,cjs}'\n      ],\n      parserOptions: {\n        sourceType: 'script'\n      }\n    }\n  ],\n  parserOptions: {\n    ecmaVersion: 'latest'\n  },\n  rules: {\n  },\n  globals: {\n    dayjs: null\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\n"
  },
  {
    "path": "README.md",
    "content": "# FreshTube\n\nA tool to display latest videos from your favourite Youtube channels.\n\nFeatures:\n- flag new videos (since last refresh)\n- fliter display by: age, duration, future publish time\n- modify the URL that is used when clicking on a video thumbnail (by default links to Youtube)\n- source Youtube channel list from a web link e.g. Nextcloud share link\n- supports RSS feed URLs aswell as Youtube\n\n\nRuns entirely in the browser, storing data in local storage.\n\nUse it here: https://porjo.github.io/freshtube/\n\n![Screenshot](https://porjo.github.io/freshtube/screenshot.jpg)\n"
  },
  {
    "path": "css/style.css",
    "content": "html, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n        margin: 0;\n        padding: 0;\n        border: 0;\n        font-size: 100%;\n        font: inherit;\n        vertical-align: baseline;\n}\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n        display: block;\n}\n\n@font-face {\n        font-family: 'Pacifico';\n        font-style: normal;\n        font-weight: 400;\n        src: local('Pacifico Regular'), local('Pacifico-Regular'), url(Pacifico.woff2) format('woff2');\n        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+\n        2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n.quiet_text {\n\tcolor: #777;\n}\n\n#title_bar {\n\tposition: relative;\n}\n\n#main_title {\n\tfont-family: 'Pacifico', cursive;\n\tmargin-top: 20px;\n        margin-bottom: 30px;\n        font-size: 2em;\n        font-weight: bold;\n        color: #1f4a4d;\n\tdisplay: inline-block;\n}\n\n#settings_button {\n\tposition: absolute;\n\tright: 0;\n\ttop: 50%;\n\ttransform: translate(0, -50%);\n\tline-height: 1;\n\tfont-size: 1.5em;\n}\n\n#settings {\n\tmargin-bottom: 50px;\n\tdisplay: none;\n}\n\n#error-box {\n\tdisplay: none;\n}\n\n.channel {\n\tposition: relative;\n\tmargin-top: 20px;\n}\n\n/*\n@media (max-width: 800px) {\n\t.channel {\n\t\tpadding: unset;\n\t\tpadding-top: 5px;\n\t}\n}\n*/\n\n.video_list {\n\tdisplay: flex;\n\talign-items: stretch;\n\toverflow-y: auto;\n\tpadding: 5px;\n}\n\n.channel_title {\n\tfont-size: 1.2em;\n\tfont-weight: bold;\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: flex-end;\n\tpadding: 5px;\n\tbackground: linear-gradient(#ececec,#fff);\n\tborder-radius: 5px;\n}\n\n#footer {\n\tborder-top: 1px solid #bbb;\n\tpadding: 10px;\n\tmargin-top: 20px;\n\tfont-size: 0.9em;\n\tdisplay: flex;\n\tjustify-content: space-between;\n}\n\n#showhide {\n\tfloat: right;\n}\n\n.video {\n\tposition: relative;\n\tbackground: #eee;\n\tpadding: 0;\n\tmargin-right: 8px;\n\twidth: 200px;\n\tmin-width: 100px;\n\tfont-size: 0.9em;\n\tdisplay: flex;\n\tflex-direction: column;\n\tcursor: pointer;\n}\n\n.video_title {\n\tpadding: 3px;\n\tbackground: #3c3c3c;\n\tcolor: #fff;\n\tflex-grow: 2;\n}\n\n.video_footer {\n\tpadding: 3px;\n\tfont-size: 0.9em;\n\tdisplay: flex;\n\tjustify-content: space-between;\n}\n\n.video_thumb img {\n\twidth: 100%;\n}\n\n.video_duration {\n\tposition: absolute;\n\ttop: 0;\n\tright: 0;\n\tcolor: #fff;\n\tfont-size: 0.9em;\n\tbackground-color: rgba(0,0,0,0.5);\n\tpadding: 2px;\n}\n\n.video_sched {\n\tdisplay: none;\n\tposition: absolute;\n\tbottom: 0;\n\tright: 0;\n\tleft: 0;\n\ttext-align: center;\n\tcolor: #fff;\n\tfont-size: 0.9em;\n\tfont-weight: bold;\n\tbackground-color: rgba(0,67,225,0.75);\n\tpadding: 2px;\n\tz-index: 1;\n}\n\n.close_channel, .show_hidden {\n\topacity: 0.5;\n\tfont-size: 1.5em;\n\tdisplay: inline;\n\tmargin: 0 5px;\n}\n\n.close_channel:hover, .show_hidden:hover {\n\topacity: 1;\n\tcursor: pointer;\n}\n\n.would_hide {\n\tborder: 2px dotted #0d05ff;\n\tdisplay: none;\n}\n\n.grey-out {\n\tfilter: grayscale(100%) opacity(50%);\n}\n\n.live {\n\tfont-weight: bold;\n\tcolor: red;\n\ttext-shadow: 0px 0px 2px black;\n\tfont-size: 1.2em;\n}\n\n.sponsorblock {\n\tposition: relative;\n}\n\n.sponsorblock img {\n\tposition: absolute;\n\theight: 100%;\n\tdisplay: none;\n}\n\n@media (max-width: 800px) {\n\t.channel_title a {\n\t\tdisplay: inline-block;\n\t\tmax-width: 70%;\n\t}\n\t.video {\n\t\tmax-width: 150px;\n\t}\n}\n\n.github-link img {\n\tmargin: 5px 10px;\n}\n\n\n/* http://www.cssportal.com/css-ribbon-generator/ */\n\n.ribbon {\n  position: absolute;\n  left: -5px; top: -5px;\n  z-index: 1;\n  overflow: hidden;\n  width: 75px; height: 75px;\n  text-align: right;\n  cursor: pointer;\n}\n.ribbon span {\n  font-size: 10px;\n  font-weight: bold;\n  color: #FFF;\n  text-transform: uppercase;\n  text-align: center;\n  line-height: 20px;\n  transform: rotate(-45deg);\n  -webkit-transform: rotate(-45deg);\n  width: 100px;\n  display: block;\n  background: #79A70A;\n  background: rgba(255,0,0,0.6);\n  box-shadow: 0 3px 10px -5px rgba(0, 0, 0, 1);\n  position: absolute;\n  top: 19px; left: -21px;\n}\n.ribbon span::before {\n  content: \"\";\n  position: absolute; left: 0px; top: 100%;\n  z-index: -1;\n  border-left: 3px solid #8F0808;\n  border-right: 3px solid transparent;\n  border-bottom: 3px solid transparent;\n  border-top: 3px solid #8F0808;\n}\n.ribbon span::after {\n  content: \"\";\n  position: absolute; right: 0px; top: 100%;\n  z-index: -1;\n  border-left: 3px solid transparent;\n  border-right: 3px solid #8F0808;\n  border-bottom: 3px solid transparent;\n  border-top: 3px solid #8F0808;\n}\n\n\n/* spinner */\n\n.lds-dual-ring,\n.lds-dual-ring:after {\n  box-sizing: border-box;\n}\n.lds-dual-ring {\n  display: inline-block;\n  width: 80px;\n  height: 80px;\n}\n.lds-dual-ring:after {\n  content: \" \";\n  display: block;\n  width: 64px;\n  height: 64px;\n  margin: 8px;\n  border-radius: 50%;\n  border: 6.4px solid currentColor;\n  border-color: currentColor transparent currentColor transparent;\n  animation: lds-dual-ring 1.2s linear infinite;\n}\n@keyframes lds-dual-ring {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n.overlay {\n\tdisplay: none;\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\theight: 100%;\n\twidth: 100%;\n\tz-index: 10;\n\tbackground-color: rgba(255, 255, 255, 0.9)\n}\n\n.spinner {\n\tposition: absolute;\n\ttop: 50%;\n\tleft: 50%;\n\ttransform: translate(-50%, -50%);\n}"
  },
  {
    "path": "index.html",
    "content": "<!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, initial-scale=1.0, minimum-scale=0.5 maximum-scale=1.0\">\n\n\t\t<title>FreshTube</title>\n\n\t\t<link rel=\"stylesheet\" href=\"css/style.css?v=1.0\">\n\t\t<link rel=\"stylesheet\" href=\"https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css\">\n\n\t</head>\n\n\t<body>\n\n\t\t<div class=\"container\">\n\t\t\t<div id=\"title_bar\">\n\t\t\t\t<h1 id=\"main_title\">FreshTube</h1>\n\t\t\t\t<button id=\"settings_button\" type=\"button\" class=\"btn btn-default btn-sm\"><span class=\"glyphicon glyphicon-cog\"></span></button>\n\t\t\t</div>\n\n\t\t\t<div id=\"error-box\" class=\"alert alert-danger\" role=\"alert\"></div>\n\n\t\t\t<form id=\"settings\" class=\"well\">\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<label for='apikey'>API Key</label>\n\t\t\t\t\t<p class='quiet_text'>Use a YouTube API key from the <a href='https://console.developers.google.com'>Google Developer's Console</a>.</p>\n\t\t\t\t\t<input type='text' id='apikey' class=\"form-control\">\n\t\t\t\t</div>\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<label for='video_urls'>Channels / Users</label>\n\t\t\t\t\t<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>\n\t\t\t\t\t<textarea id=\"video_urls\" rows=8 cols=50 class=\"form-control\"></textarea>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<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>\n\t\t\t\t\t<input type='text' id=\"weblink_url\" class=\"form-control\">\n\t\t\t\t</div>\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<input type=\"checkbox\" id='highlight_new' value=1 checked> highlight new videos (since last refresh)<br>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<label for=\"hide_group\">Hide Videos</label>\n\t\t\t\t\t<div id=\"hide_group\" class=\"input-group\">\n\t\t\t\t\t\t<input type=\"checkbox\" id='hide_old_check'> older than <input id=\"hide_old_days\" type=\"number\" size=3 value=\"7\" min=1 /> days<br>\n\t\t\t\t\t\t<input type=\"checkbox\" id='hide_future_check'> screening &gt; <input id=\"hide_future_hours\" type=\"number\" size=3 value=\"2\" min=1 /> hours in the future<br>\n\t\t\t\t\t\t<input type=\"checkbox\" id='hide_time_check'> &lt; <input id=\"hide_time_mins\" type=\"number\" size=3 value=\"20\" min=1 /> minutes in duration\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<label for=\"hide_group\">Cache</label>\n\t\t\t\t\t<div id=\"hide_group\" class=\"input-group\">\n\t\t\t\t\t\tcache result for <input id=\"cache_result_mins\" type=\"number\" size=3 min=0 /> minutes (0 is no cache)\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"form-group\">\n\t\t\t\t\t<label for='vc_target'>Video Click Target</label>\n\t\t\t\t\t<input type=\"url\" id='vc_target' class=\"form-control\" placeholder=\"https://videos.example.com/?video_url=%v\">\n\t\t\t\t\t<small class='form-text text-muted'>Used to override video click. Use macro '%v' to insert video URL into target URL (URL encoded).</small>\n\t\t\t\t</div>\n\t\t\t\t<button id='save_button' type=\"button\" class=\"btn btn-success btn-lg\">Save</button>\n\t\t\t</form>\n\n\t\t\t<div id='videos'></div>\n\n\t\t\t<div id=\"footer\">\n\t\t\t\t<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>\n\t\t\t\t<div class='github-link'><a href='https://github.com/porjo/freshtube'><img src='github-mark.svg' width=\"25px\"/></a></div>\n\t\t\t</div>\n\n\t\t\t<div class=\"overlay\">\n\t\t\t\t<div class=\"spinner\">\n\t\t\t\t\t<div class=\"lds-dual-ring\"></div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js\"></script>\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/dayjs.min.js\"></script>\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/plugin/relativeTime.min.js\"></script>\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/plugin/duration.min.js\"></script>\n\n\t\t<script src=\"js/script.js\"></script>\n\n\t</body>\n</html>\n"
  },
  {
    "path": "js/script.js",
    "content": "/* eslint-disable no-unused-expressions */\n/* eslint-disable no-undef */\n'use strict'\n\n// Extend dayjs plugins\ndayjs.extend(window.dayjs_plugin_relativeTime)\ndayjs.extend(window.dayjs_plugin_duration)\n\nclass YouTubeAPIConstants {\n  static CHANNEL_URL = 'https://www.googleapis.com/youtube/v3/channels?part=contentDetails'\n  static PLAYLIST_URL = 'https://www.googleapis.com/youtube/v3/playlistItems?part=snippet'\n  static DURATION_URL = 'https://www.googleapis.com/youtube/v3/videos?part=contentDetails'\n  static LIVE_BROADCAST_URL = 'https://www.googleapis.com/youtube/v3/videos?part=snippet,liveStreamingDetails'\n  static WATCH_URL = 'https://www.youtube.com/watch'\n  static SPONSOR_BLOCK_URL = 'https://sponsor.ajay.app/api/skipSegments'\n  static ITUNES_NAMESPACE = 'http://www.itunes.com/dtds/podcast-1.0.dtd'\n}\n\nclass RegexPatterns {\n  static CHANNEL = /youtube\\.com\\/channel\\/([^/]+)\\/?/\n  static CHANNEL_NAME = /youtube\\.com\\/c\\/([^/]+)\\/?/\n  static USER = /youtube\\.com\\/user\\/([^/]+)\\/?/\n  static HANDLE = /youtube\\.com\\/(@[^/]+)\\/?/\n  static RSS = /(\\/feed|rss|\\.xml)/\n  static NEXTCLOUD = /s\\/[a-zA-Z0-9]{15}(\\/download\\/?)?$/\n}\n\nclass ConfigManager {\n  constructor () {\n    this.config = {\n      key: '',\n      lastRefresh: null,\n      highlightNew: true,\n      hideOldCheck: true,\n      hideOldDays: 1,\n      hideFutureCheck: true,\n      hideFutureHours: 2,\n      hideTimeCheck: false,\n      hideTimeMins: 20,\n      videoClickTarget: null,\n      weblinkURL: null,\n      cacheResultMins: 15,\n      cachedResult: null\n    }\n  }\n\n  async load () {\n    if (typeof Storage === 'undefined') return\n\n    const storedConfig = localStorage.getItem('freshtube_config')\n    if (storedConfig) {\n      this.config = { ...this.config, ...JSON.parse(storedConfig) }\n    }\n\n    this.updateUI()\n    if (this.config.lines || this.config.weblinkURL) {\n      await this.handleCache()\n    }\n    this.save()\n  }\n\n  save () {\n    this.config.lines = $('#video_urls').val().split('\\n').filter(i => i)\n    this.config.highlightNew = $('#highlight_new').is(':checked')\n    this.config.lastRefresh = dayjs().toISOString()\n    this.config.hideOldCheck = $('#hide_old_check').is(':checked')\n    this.config.hideOldDays = Number($('#hide_old_days').val())\n    this.config.hideFutureCheck = $('#hide_future_check').is(':checked')\n    this.config.hideFutureHours = Number($('#hide_future_hours').val())\n    this.config.hideTimeCheck = $('#hide_time_check').is(':checked')\n    this.config.hideTimeMins = Number($('#hide_time_mins').val())\n    this.config.videoClickTarget = $('#vc_target').val()\n    this.config.weblinkURL = $('#weblink_url').val()\n    this.config.cacheResultMins = $('#cache_result_mins').val()\n\n    localStorage.setItem('freshtube_config', JSON.stringify(this.config))\n  }\n\n  updateUI () {\n    $('#apikey').val(this.config.key)\n    $('#highlight_new').prop('checked', this.config.highlightNew)\n    $('#hide_old_check').prop('checked', this.config.hideOldCheck)\n    $('#hide_old_days').val(this.config.hideOldDays)\n    $('#hide_future_check').prop('checked', this.config.hideFutureCheck)\n    $('#hide_future_hours').val(this.config.hideFutureHours)\n    $('#hide_time_check').prop('checked', this.config.hideTimeCheck)\n    $('#hide_time_mins').val(this.config.hideTimeMins)\n    $('#cache_result_mins').val(this.config.cacheResultMins)\n    $('#vc_target').val(this.config.videoClickTarget)\n    $('#weblink_url').val(this.config.weblinkURL)\n    $('#video_urls').val(this.config.lines?.join('\\n') || '')\n  }\n\n  async handleCache () {\n    console.time('cache')\n    if (!this.config.cacheResultMins || !this.config.lastRefresh ||\n        dayjs().subtract(this.config.cacheResultMins, 'minutes').isAfter(this.config.lastRefresh)) {\n      await videoManager.refresh()\n      this.config.cachedResult = $('#videos').html()\n    } else {\n      $('#videos').html(this.config.cachedResult)\n    }\n    console.timeLog('cache', 'content loaded')\n  }\n}\n\nclass VideoManager {\n  constructor (configManager) {\n    this.configManager = configManager\n    this.ytIds = []\n    this.videos = ''\n    this.rssItemLimit = 20\n  }\n\n  async refresh () {\n    $('#error-box').hide()\n    this.configManager.config.key = $('#apikey').val()\n\n    if (!this.configManager.config.key) {\n      uiManager.showError('API key cannot be empty')\n      return\n    }\n\n    this.ytIds = []\n    const lines = await this.getLines()\n    await this.processLines(lines)\n  }\n\n  async getLines () {\n    let lines = $('#video_urls').val().split('\\n')\n    this.configManager.config.weblinkURL = $('#weblink_url').val()\n\n    if (this.configManager.config.weblinkURL) {\n      const url = this.formatWeblinkURL()\n      try {\n        const data = await this.fetchData(url, false)\n        lines = data.split(/\\n/).concat(lines)\n      } catch (error) {\n        uiManager.showError(`failed to fetch web link - check CORS headers: ${error.message}`)\n      }\n    }\n    return Array.from(new Set(lines))\n  }\n\n  formatWeblinkURL () {\n    let url = this.configManager.config.weblinkURL\n    const found = url.match(RegexPatterns.NEXTCLOUD)\n    if (found && !found[1]) {\n      url += '/download'\n    }\n    return url\n  }\n\n  async processLines (lines) {\n    $('#videos').html('')\n    $('.overlay').show()\n\n    const promises = lines.map(line => this.processLine(line))\n    await Promise.all(promises)\n    await this.getDurations()\n\n    uiManager.sortChannels()\n    uiManager.updateHiddenItemsStatus()\n\n    await Promise.all([this.getSponsorBlock(), this.getLiveBroadcasts()])\n\n    $('.overlay').hide()\n  }\n\n  async processLine (line) {\n    if (!line.trim() || line.match(/^#/)) return\n\n    $('#settings').slideUp()\n\n    if (RegexPatterns.RSS.test(line)) {\n      return this.handleRSS(line)\n    } else {\n      return this.handleYouTubeChannel(line)\n    }\n  }\n\n  async fetchData (url, json = true) {\n    const response = await fetch(url)\n    if (!response.ok) {\n      throw new Error(`network response was not ok: ${response.statusText}`)\n    }\n    return json ? response.json() : response.text()\n  }\n\n  async handleRSS (line) {\n    try {\n      const data = await this.fetchData(line, false)\n      const parser = new DOMParser()\n      const doc = parser.parseFromString(data, 'text/xml')\n      const channel = doc.querySelector('channel')\n\n      const channelTitle = channel.querySelector('title')?.textContent || ''\n      const channelURL = channel.querySelector('link')?.textContent || ''\n      const channelImageURL = channel.querySelector('image url')?.textContent || ''\n\n      let videosOuter = `<div class=\"channel\">\n                <div class=\"channel_title\"><a href=\"${channelURL}\" title=\"${line}\" target=\"_blank\">${channelTitle}</a></div>\n                <div class=\"video_list\">`\n      this.videos = ''\n\n      const rssVids = Array.from(channel.querySelectorAll('item'))\n        .slice(0, this.rssItemLimit)\n        .map(item => this.processRSSItem(item, channelImageURL))\n\n      rssVids.forEach(v => this.videoHTML(v))\n\n      videosOuter += this.videos || '<i>no videos found</i>'\n      videosOuter += '</div></div>'\n\n      $('#videos').append(videosOuter)\n    } catch (error) {\n      uiManager.showError(error.message)\n    }\n  }\n\n  processRSSItem (item, channelImageURL) {\n    const imageElements = item.getElementsByTagNameNS(YouTubeAPIConstants.ITUNES_NAMESPACE, 'image')\n    // item image overrides any channel image\n    const itemImageURL = imageElements.length ? imageElements[0].getAttribute('href') : channelImageURL\n\n    const enclosureElement = item.querySelector('enclosure')\n    const watchURL = enclosureElement?.getAttribute('url') ||\n                        item.querySelector('link')?.textContent || ''\n\n    const durationElements = item.getElementsByTagNameNS(YouTubeAPIConstants.ITUNES_NAMESPACE, 'duration')\n    const duration = durationElements.length ? durationElements[0].textContent : ''\n\n    return {\n      snippet: {\n        title: item.querySelector('title')?.textContent || '',\n        resourceId: { videoId: item.querySelector('guid')?.textContent || '' },\n        thumbnails: { medium: { url: itemImageURL } },\n        publishedAt: item.querySelector('pubDate')?.textContent || '',\n        watchURL,\n        duration\n      }\n    }\n  }\n\n  async handleYouTubeChannel (line) {\n    let url = `${YouTubeAPIConstants.CHANNEL_URL}&key=${this.configManager.config.key}`\n    let channelURL = 'https://www.youtube.com/'\n    const chanMatches = line.match(RegexPatterns.CHANNEL)\n    const chanNameMatches = line.match(RegexPatterns.CHANNEL_NAME)\n    const userMatches = line.match(RegexPatterns.USER)\n    const handleMatches = line.match(RegexPatterns.HANDLE)\n\n    if (chanMatches?.[1]) {\n      channelURL += `channel/${chanMatches[1]}`\n      url += `&id=${chanMatches[1]}`\n    } else if (userMatches?.[1]) {\n      channelURL += `user/${userMatches[1]}`\n      url += `&forUsername=${userMatches[1]}`\n    } else if (handleMatches?.[1]) {\n      channelURL += handleMatches[1]\n      url += `&forHandle=${handleMatches[1]}`\n    } else if (chanNameMatches?.[1]) {\n      // try channel name as handle\n      channelURL += chanNameMatches[1]\n      url += `&forHandle=${chanNameMatches[1]}`\n    } else {\n      const id = line.trim()\n      url += id.length === 24 ? `&id=${id}` : `&forUsername=${id}`\n      channelURL += id.length === 24 ? `channel/${id}` : `user/${id}`\n    }\n\n    try {\n      const data = await this.fetchData(url)\n      if (data?.items) {\n        const playlistID = data.items[0].contentDetails.relatedPlaylists.uploads\n        const playlistUrl = `${YouTubeAPIConstants.PLAYLIST_URL}&key=${this.configManager.config.key}&playlistId=${playlistID}`\n        const playlistData = await this.fetchData(playlistUrl)\n        await this.handlePlaylist(channelURL, playlistData)\n      }\n    } catch (error) {\n      uiManager.showError(error.message)\n    }\n  }\n\n  async handlePlaylist (channelURL, data) {\n    if (!data?.items?.length) return\n\n    data.items.sort((a, b) => dayjs(a.snippet.publishedAt).isBefore(b.snippet.publishedAt) ? 1 : -1)\n\n    let videosOuter = `<div class=\"channel\">\n            <div class=\"channel_title\"><a href=\"${channelURL}/videos\" target=\"_blank\">${data.items[0].snippet.channelTitle}</a></div>\n            <div class=\"video_list\">`\n    this.videos = ''\n\n    data.items.forEach(v => this.videoHTML(v))\n\n    videosOuter += this.videos || '<i>no videos found</i>'\n    videosOuter += '</div></div>'\n\n    $('#videos').append(videosOuter)\n  }\n\n  async getSponsorBlock () {\n    const promises = this.ytIds.filter(id => id.length === 11).map(async videoId => {\n      try {\n        const response = await fetch(`${YouTubeAPIConstants.SPONSOR_BLOCK_URL}?videoID=${videoId}`)\n        if (response.ok) {\n          const data = await response.json()\n          if (Array.isArray(data) && data.length) {\n            $(`.video[data-id=\"${videoId}\"] .sponsorblock > img`).show()\n          }\n        }\n      } catch (error) {\n        uiManager.showError(`failed to fetch SponsorBlock: ${error.message}`)\n      }\n    })\n    return Promise.all(promises)\n  }\n\n  async getDurations () {\n    const url = `${YouTubeAPIConstants.DURATION_URL}&key=${this.configManager.config.key}&id=${this.ytIds.join(',')}`\n    try {\n      const data = await this.fetchData(url)\n      data.items.forEach(v => this.processDuration(v))\n    } catch (error) {\n      uiManager.showError(`failed to fetch durations: ${error.message}`)\n    }\n  }\n\n  processDuration (v) {\n    const duration = dayjs.duration(v.contentDetails.duration)\n    const durationStr = `${duration.hours() > 0 ? duration.hours() + ':' : ''}${pad(duration.minutes(), 2)}:${pad(duration.seconds(), 2)}`\n\n    $(`.video[data-id=\"${v.id}\"] .video_duration`).text(durationStr)\n\n    const minutes = duration.as('minutes')\n    if (this.configManager.config.hideTimeCheck && minutes > 0 && minutes < this.configManager.config.hideTimeMins) {\n      $(`.video[data-id=\"${v.id}\"]`).addClass('would_hide')\n    }\n  }\n\n  async getLiveBroadcasts () {\n    const url = `${YouTubeAPIConstants.LIVE_BROADCAST_URL}&key=${this.configManager.config.key}&id=${this.ytIds.join(',')}`\n    try {\n      const data = await this.fetchData(url)\n      data.items.forEach(v => this.processLiveBroadcast(v))\n    } catch (error) {\n      uiManager.showError(`failed to fetch live broadcasts: ${error.message}`)\n    }\n  }\n\n  processLiveBroadcast (v) {\n    if (v.snippet.liveBroadcastContent === 'upcoming') {\n      if (this.configManager.config.hideFutureCheck &&\n          dayjs().add(this.configManager.config.hideFutureHours, 'hours').isBefore(v.liveStreamingDetails.scheduledStartTime)) {\n        $(`.video[data-id=\"${v.id}\"]`).addClass('would_hide')\n      }\n      $(`.video[data-id=\"${v.id}\"] .video_sched`).text(dayjs(v.liveStreamingDetails.scheduledStartTime).fromNow()).show()\n      $(`.video[data-id=\"${v.id}\"] .video_thumb img`).addClass('grey-out')\n    } else if (v.snippet.liveBroadcastContent === 'live') {\n      $(`.video[data-id=\"${v.id}\"] .video_duration`).html('<div class=\"live\"><span class=\"glyphicon glyphicon-record\"></span> Live</div>')\n    }\n  }\n\n  videoHTML (v) {\n    if (this.configManager.config.hideOldCheck &&\n        dayjs().subtract(this.configManager.config.hideOldDays, 'days').isAfter(v.snippet.publishedAt)) {\n      return\n    }\n\n    const id = v.snippet.resourceId.videoId\n    let rssHide = false\n    let rssLive = false\n    let duration\n\n    if ('duration' in v.snippet) {\n      if (!v.snippet.duration) {\n        rssLive = true\n      } else {\n        duration = v.snippet.duration.includes(':')\n          ? dayjs.duration(hmsToSecondsOnly(v.snippet.duration), 'seconds')\n          : dayjs.duration(v.snippet.duration, 'seconds')\n        if (this.configManager.config.hideTimeCheck && duration.as('minutes') < this.configManager.config.hideTimeMins) {\n          rssHide = true\n        }\n      }\n    } else {\n      this.ytIds.push(id)\n    }\n\n    const fullTitle = v.snippet.title\n    const title = fullTitle.length > 50 ? fullTitle.substring(0, 50) + '...' : fullTitle\n    const watch = 'watchURL' in v.snippet ? v.snippet.watchURL : `${YouTubeAPIConstants.WATCH_URL}?v=${id}`\n    const clickURL = this.getClickURL(watch)\n\n    let video = `<a class=\"video${rssHide ? ' would_hide' : ''}\" data-id=\"${id}\" href=\"${clickURL}\" target=\"_blank\">\n            <div class=\"video_thumb\"><div class=\"video_sched\"></div><img src=\"${v.snippet.thumbnails.medium.url}\"></div>\n            <div class=\"video_title\" title=\"${fullTitle}\">${title}</div>`\n\n    if (duration) {\n      video += `<div class=\"video_duration\">${duration.hours() > 0 ? duration.hours() + ':' : ''}${pad(duration.minutes(), 2)}:${pad(duration.seconds(), 2)}</div>`\n    } else if (rssLive) {\n      video += '<div class=\"video_duration\"><div class=\"live\"><span class=\"glyphicon glyphicon-record\"></span> Live</div></div>'\n    } else {\n      video += '<div class=\"video_duration\"></div>'\n    }\n\n    const publishedAt = dayjs(v.snippet.publishedAt)\n    video += `<div class=\"video_footer\">\n            <div class=\"sponsorblock\"><img src=\"sponsorblock.png\"></div>\n            <div class=\"age\" data-unix=\"${publishedAt.unix()}\">${publishedAt.fromNow()}</div>\n        </div>`\n\n    if (this.configManager.config.lastRefresh && this.configManager.config.highlightNew &&\n        dayjs(this.configManager.config.lastRefresh).isBefore(v.snippet.publishedAt)) {\n      video += '<div class=\"ribbon\"><span>New</span></div>'\n    }\n    video += '</a>'\n\n    this.videos += video\n  }\n\n  getClickURL (url) {\n    return this.configManager.config.videoClickTarget\n      ? this.configManager.config.videoClickTarget.replace('%v', encodeURIComponent(url))\n      : url\n  }\n}\n\nclass UIManager {\n  showError (message) {\n    $('.overlay').hide()\n    window.scrollTo({ top: 0, behavior: 'smooth' })\n    let errMsg = typeof message === 'string' ? message : 'Unknown error occurred'\n\n    if (typeof message === 'object' && 'responseJSON' in message) {\n      errMsg = message.responseJSON.error.errors\n        .map(val => `${val.reason}, ${val.message}`)\n        .join('; ')\n    }\n\n    $('#error-box').text(`Error: ${errMsg}`).show()\n  }\n\n  sortChannels () {\n    const list = $('#videos')[0]\n    const listItems = Array.from(list.children)\n\n    listItems.sort((a, b) => {\n      const aAges = a.querySelectorAll('.video:not(.would_hide) .age')\n      const bAges = b.querySelectorAll('.video:not(.would_hide) .age')\n\n      if (!aAges.length && !bAges.length) return 0\n      if (aAges.length && !bAges.length) return -1\n      if (bAges.length && !aAges.length) return 1\n\n      if (aAges.length && bAges.length) {\n        const aUnix = aAges[0].getAttribute('data-unix')\n        const bUnix = bAges[0].getAttribute('data-unix')\n        return aUnix > bUnix ? -1 : 1\n      }\n\n      const aListNew = a.querySelectorAll('.video:not(.would_hide) .ribbon')\n      const bListNew = b.querySelectorAll('.video:not(.would_hide) .ribbon')\n      if (aListNew.length > bListNew.length) return -1\n\n      const aList = a.querySelectorAll('.video:not(.would_hide)')\n      const bList = b.querySelectorAll('.video:not(.would_hide)')\n      return aList.length > bList.length ? -1 : 1\n    }).forEach(node => list.appendChild(node))\n  }\n\n  updateHiddenItemsStatus () {\n    $('.channel').each(function () {\n      const hasHidden = $(this).find('.video_list .video').is(':hidden')\n      if (hasHidden) {\n        $(this).find('.channel_title')\n          .append('<span class=\"show_hidden glyphicon glyphicon-eye-open\"></span>')\n      }\n    })\n  }\n}\n\n// Initialize\nconst configManager = new ConfigManager()\nconst uiManager = new UIManager()\nconst videoManager = new VideoManager(configManager)\n\n// Event listeners\n$(document).ready(() => {\n  configManager.load()\n  if (!configManager.config.key) $('#settings').slideDown()\n\n  $('body').on('click', '.show_hidden', function () {\n    $(this).closest('.channel').find('.would_hide').slideToggle(200)\n  })\n\n  $('#settings_button').click(() => $('#settings').slideToggle(200))\n\n  $('#save_button').click(async () => {\n    configManager.save()\n    await videoManager.refresh()\n  })\n})\n\n// Utility functions\nfunction pad (n, width, z = '0') {\n  n = n + ''\n  return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n\n}\n\n// Credit: https://stackoverflow.com/a/9640417/202311\nfunction hmsToSecondsOnly (str) {\n  const p = str.split(':')\n  let s = 0\n  let m = 1\n  while (p.length > 0) {\n    s += m * parseInt(p.pop(), 10)\n    m *= 60\n  }\n  return s\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"freshtube\",\n  \"version\": \"1.0.0\",\n  \"description\": \"A tool to display latest videos from your favourite Youtube channels.\",\n  \"main\": \".eslintrc.js\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"keywords\": [],\n  \"author\": \"\",\n  \"license\": \"ISC\",\n  \"devDependencies\": {\n    \"eslint\": \"^8.56.0\",\n    \"eslint-config-standard\": \"^17.1.0\",\n    \"eslint-plugin-import\": \"^2.29.1\",\n    \"eslint-plugin-n\": \"^16.6.2\",\n    \"eslint-plugin-promise\": \"^6.1.1\"\n  }\n}\n"
  }
]