Skip to content

Instantly share code, notes, and snippets.

@ericboehs
Last active November 13, 2023 18:49
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ericboehs/79e7799829d86c3b84d449ad3ce952cd to your computer and use it in GitHub Desktop.
Save ericboehs/79e7799829d86c3b84d449ad3ce952cd to your computer and use it in GitHub Desktop.
Offline YouTube
#! /usr/bin/env ruby
# Eric's Offline YouTube (Download YT videos via Downie with JSON metadata and image previews and then run this script)
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'json'
gem 'pry'
gem 'puma'
gem 'sinatra'
end
DOWNLOAD_PATH = "#{ENV['HOME']}/Downloads/YouTube/".freeze
END_OF_FILE = DATA.pos.freeze
class DownieJSON
attr_reader :file
def initialize file
@file = file
update_json unless raw_json['lengthInSeconds']
end
def json
@json ||= raw_json.merge(
'progress' => progress,
'url' => url
)
end
def marked_watched
File.write file, raw_json.merge(
'watched' => 'watched'
).to_json
end
def launch_iina
`open "#{offline_url}"`
end
private
def progress
(start / raw_json['lengthInSeconds'].to_f * 100).to_i rescue 0
end
def start
md5_of_file_path = Digest::MD5.hexdigest video_path
watch_later_path = '/Users/ericboehs/Library/Application Support/com.colliderli.iina/watch_later'
watch_later_file = "#{watch_later_path}/#{md5_of_file_path.upcase}"
return 0 unless File.exist? watch_later_file
File.readlines(watch_later_file).grep(/start=/).first&.chomp&.split('=')&.last.to_i
end
def update_json
File.write file, raw_json.merge(
'file' => file,
'addedAtEpoch' => added_at_epoch,
'offlineURL' => offline_url,
'previewImageURL' => preview_image_url,
'uploadDateEpoch' => upload_date_epoch,
'length' => length,
'lengthInSeconds' => length_in_seconds
).to_json
end
def url
return unless raw_json['url']
uri = URI.parse raw_json['url']
return unless uri.query
query = URI.decode_www_form(uri.query).to_h
query['t'] = start
uri.query = URI.encode_www_form query
uri.to_s
end
def length
return raw_json['length'] if raw_json['length']
duration = `mdls "#{file.sub /\.[^\.]+$/, '.mp4'}" | grep Duration`.chomp
seconds = duration.split('= ').last.to_i
Time.at(seconds).utc.strftime(seconds < 3600 ? '%M:%S' : '%H:%M:%S')
end
def length_in_seconds
return raw_json['lengthInSeconds'] if raw_json['lengthInSeconds']
duration = `mdls "#{file.sub /\.[^\.]+$/, '.mp4'}" | grep Duration`.chomp
duration.split('= ').last.to_i
end
def raw_json
JSON.parse file_contents
end
def offline_url
return "iina://open?url=#{ERB::Util.url_encode video_path}" if File.exist? video_path
ERB::Util.url_encode raw_json['url']
end
def video_path
file.sub /\.[^\.]+$/, '.mp4'
end
def preview_image_url
expected_path = file.sub /\.[^\.]+$/, '.jpg'
if File.exist? expected_path
return "/thumbnails/#{ERB::Util.url_encode expected_path.gsub DOWNLOAD_PATH, ''}"
end
if raw_json['previewImageURL']
raw_json['previewImageURL']
else
generate_preview_image
end
end
def generate_preview_image
`qlmanage -t "#{video_path}" -s 512 -o "#{DOWNLOAD_PATH}"`
file_name = video_path.gsub(DOWNLOAD_PATH, '') + '.png'
"/thumbnails/#{ERB::Util.url_encode file_name}"
end
def added_at_epoch
added = `GetFileInfo -d "#{file}"`.chomp
return 0 if added.empty?
time = Time.strptime added, '%m/%d/%Y %H:%M:%S'
time.to_i
end
def upload_date_epoch
date = Date.parse raw_json['uploadDate'] || raw_json['prepareDate']
date.to_time.to_i
end
def file_contents
File.read file
end
end
class YouTubeFiles
def initialize(sort = nil)
@sort = sort || 'addedAtEpoch'
end
def json
downie_files.map { |df| df.json }
end
def downie_files
json_files
.map { |file| DownieJSON.new(file) }
.sort_by { |item| item.json[@sort] || item.json['prepareDate'] }
.reverse
end
def json_files
Dir["#{DOWNLOAD_PATH}*.json"]
end
end
set :bind, '0.0.0.0'
set :port, 7777
get '/' do
DATA.pos = END_OF_FILE
videos = YouTubeFiles.new params[:sort]
ERB.new(DATA.read).result binding
end
get '/launch' do
video = DownieJSON.new(params[:file])
video.launch_iina
redirect '/'
end
get '/watched' do
video = DownieJSON.new(params[:file])
video.marked_watched
redirect "#{video.json['url'].gsub "&t=0", ''}&t=#{video.json['lengthInSeconds'].to_i - 10}"
end
get '/assets/:file' do
send_file "#{Dir.pwd}/yt-assets/#{params[:file]}", disposition: 'inline'
end
get '/thumbnails/:file' do
send_file "#{DOWNLOAD_PATH}#{params[:file]}", disposition: 'inline'
end
get '/delete' do
video = DownieJSON.new(params[:file])
video.marked_watched
url = video.json['url']
length = video.json['lengthInSeconds']
file = params[:file]
if file.start_with? DOWNLOAD_PATH
file = File.basename file, File.extname(file)
FileUtils.rm Dir["#{File.join DOWNLOAD_PATH, file}*"]
end
redirect "#{url.gsub "&t=0", ''}&t=#{length.to_i - 10}"
end
def fetch_assets
return if Dir.exist? 'yt-assets'
FileUtils.mkdir 'yt-assets'
`cd yt-assets; curl -O https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/css/uikit.min.css`
`cd yt-assets; curl -O https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/js/uikit.min.js`
`cd yt-assets; curl -O https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/js/uikit-icons.min.js`
end
`which iina`
unless $?.success?
$stderr.puts "IINA not found. Please run `brew install --cask iina` to proceed."
exit 1
end
fetch_assets
Sinatra::Application.run!
__END__
<title>Eric's Offline YouTube</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-5 w-5' viewBox='0 0 20 20' fill='red'%3E%3Cpath fill-rule='evenodd' d='M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z' clip-rule='evenodd' /%3E%3C/svg%3E" type="image/svg+xml" />
<link rel="stylesheet" href="./assets/uikit.min.css" />
<script src="./assets/uikit.min.js"></script>
<script src="./assets/uikit-icons.min.js"></script>
<style>
.line-clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.uk-progress {
border-radius: 0;
height: 5px !important;
}
.uk-progress::-webkit-progress-value {
border-radius: 0;
background-color: red !important
}
</style>
<div class="uk-margin-auto uk-width-xlarge uk-margin-top">
<nav class="uk-navbar-container" uk-navbar>
<div class="uk-navbar-left">
<div class="uk-navbar-item">
<form class="uk-search uk-search-navbar">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search" autofocus="true">
</form>
</div>
</div>
</nav>
</div>
<div class="uk-flex uk-flex-center uk-flex-wrap uk-flex-wrap-around uk-margin-top" uk-margin="margin: uk-margin-small-top">
<% videos.json.each do |video| %>
<div class="uk-card uk-card-hover uk-width-1-6@m uk-margin-left">
<div class="uk-visible-toggle uk-inline">
<a href="launch?file=<%= ERB::Util.url_encode video['file'] %>" class="iina-launch">
<div class="uk-inline uk-light">
<img src="<%= video['previewImageURL'] || 'https://via.placeholder.com/300x168?text=No+Thumbnail' %>" width="300" class="<%= video['watched'].nil? || video['watched']&.empty? ? 'unwatched' : 'watched' %>" />
<div class="uk-position-center uk-invisible-hover">
<span uk-icon="icon: play-circle; ratio: 3"></span>
</div>
<span class="uk-label uk-position-bottom-right uk-margin-small-bottom uk-margin-small-right uk-light" style="background-color: #111; color: #FFF"><%= video['length'] %></span>
<% progress_value = 100 if video['watched'] %>
<% progress_value ||= video['progress'] %>
<progress id="js-progressbar" class="uk-progress uk-position-bottom uk-margin-remove" value="<%= progress_value %>" max="100"></progress>
</div>
</a>
</div>
<div class="uk-margin-small-left uk-margin-small-right uk-margin-small-bottom">
<div class="uk-grid uk-grid-small uk-flex-middle">
<div class="uk-margin-auto uk-width-expand">
<a href="<%= video['url'] %>" class="uk-button uk-button-text">
<h3 class="uk-h5 uk-margin-remove-bottom uk-text-left line-clamp"><%= video['title'] %></h3>
</a>
<div class="uk-text-meta uk-margin-small-top uk-child-width-expand@s uk-text-center uk-grid uk-grid-small">
<span><%= video['authors']&.join ', ' %></span>
<time class="ago" datetime="<%= video['uploadDateEpoch'] %>"><%= video['uploadDate'] %></time>
<div>
<a href="watched?file=<%= ERB::Util.url_encode video['file'] %>" target="_blank" class="uk-button uk-button-text" uk-icon="icon: check"></a>
<a href="delete?file=<%= ERB::Util.url_encode video['file'] %>" target="_blank" class="uk-button uk-button-text delete" uk-icon="icon: trash"></a>
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
<!-- Time ago -->
<script>
function timeSince(date) {
var seconds = Math.floor(((new Date().getTime()/1000) - date)), interval = Math.floor(seconds / 31536000)
if (interval > 1) return interval + " years"
interval = Math.floor(seconds / 2592000)
if (interval > 1) return interval + " months"
interval = Math.floor(seconds / 86400)
return interval + " days"
}
function setTimeAgos() {
Array.from(document.querySelectorAll('time.ago')).forEach((time) => {
var text = timeSince(time.dateTime) + ' ago'
text = text.replace('0 days ago', 'Today')
text = text.replace('1 days ago', 'Yesterday')
time.textContent = text
})
}
setTimeAgos()
setInterval(setTimeAgos, 10000)
</script>
<!-- Launch IINA -->
<script>
function launchIina(url) {
var request = new XMLHttpRequest()
request.open('GET', url, true)
request.send()
}
Array.from(document.querySelectorAll('a.iina-launch')).forEach((launchLinks) => {
launchLinks.addEventListener('click', function (e) {
launchIina(this.href)
e.preventDefault()
}, false)
})
</script>
<!-- Hide Deleted Videos -->
<script>
Array.from(document.querySelectorAll('a.delete')).forEach((deleteLinks) => {
deleteLinks.addEventListener('click', function (e) {
this.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.remove()
}, false)
})
</script>
<!-- Search -->
<script>
function filterVideos(query) {
Array.from(document.querySelectorAll('.uk-card')).forEach((video) => {
titleMatches = video.querySelector('h3').textContent.toLowerCase().includes(query)
channelMatches = video.querySelector('.uk-text-meta span').textContent.toLowerCase().includes(query)
video.hidden = !titleMatches && !channelMatches
})
}
document.querySelector('.uk-search-input').addEventListener('input', function (e) {
filterVideos(this.value.toLowerCase())
e.preventDefault()
})
document.querySelector('form').addEventListener('submit', event => { event.preventDefault() } )
</script>
@ericboehs
Copy link
Author

ericboehs commented Feb 4, 2022

Install and Run

mkdir ~/bin
curl https://gist.githubusercontent.com/ericboehs/79e7799829d86c3b84d449ad3ce952cd/raw/yt > ~/bin/yt
chmod +x ~/bin/yt
~/bin/yt

Notes

  • Configure Downie to download thumbnails and metadata (in Destination settings).
  • Also force to download as MP4.
  • Download videos to ~/Downloads/YouTube, start this script, and the navigate to http://127.0.0.1:7777 to browse your offline YouTube collection.

Clicking thumbnails will open in IINA. Clicking titles will navigate to the original YouTube URL.

Screenshot:

image

Development

gem install rerun
rerun -b --pattern 'yt' yt

@ericboehs
Copy link
Author

ericboehs commented Apr 14, 2022

I made a LaunchAgent to keep yt running in the background (store in ~/Library/LaunchAgents):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>/Users/ericboehs/bin:.git/safe/../../bin:.git/safe/../../.bundle/bundle/bin:.git/safe/../../node_modules/.bin:.git/safe/../../vendor/bundle/bin:/Users/ericboehs/.asdf/shims:/Users/ericboehs/.asdf/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/Apple/usr/bin:/opt/homebrew/opt/fzf/bin</string>
    </dict>
    <key>KeepAlive</key>
    <true/>
    <key>Label</key>
    <string>com.boehs.yt</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/ericboehs/.asdf/shims/ruby</string>
      <string>/Users/ericboehs/bin/yt</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/Users/ericboehs/Library/Logs/com.boehs.yt/stdout.log</string>
    <key>StandardOutPath</key>
    <string>/Users/ericboehs/Library/Logs/com.boehs.yt/stdout.log</string>
    <key>WorkingDirectory</key>
    <string>/Users/ericboehs/bin/</string>
  </dict>
</plist>
launchctl unload ~/Library/LaunchAgents/com.boehs.yt.plist; launchctl load -w ~/Library/LaunchAgents/com.boehs.yt.plist; tail -f ~/Library/Logs/com.boehs.yt/stdout.log

I had to add /usr/bin/env to "Full Disk Access" in Security pref pane.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment