-
-
Save ericboehs/79e7799829d86c3b84d449ad3ce952cd to your computer and use it in GitHub Desktop.
Offline YouTube
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /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> |
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
Install and Run
Notes
Clicking thumbnails will open in IINA. Clicking titles will navigate to the original YouTube URL.
Screenshot:
Development
gem install rerun rerun -b --pattern 'yt' yt