序
本記事はグレーな要素を取り扱う。
yt-dlpを用いてチャンネルの動画をすべてアーカイブすることを前提とし、しかしそのチャンネルの動画の特定プレイリストのみからなるデータも欲しいとする。
場合によっては別途プレイリストをダウンロードするのも悪くないが、動画のサイズは大きいし、ダウンロード面でもストレージ面でも厳しい。そこで、既にあるアーカイブからプレイリストを構成したい。
yt-dlpにはそのような機能はないため、yt-dlpが持っている連携機能を活かして、力技で実現していく。
アーカイブ
まずはチャンネル全動画アーカイブはこんな感じ。
#!/bin/zsh
config_file="$1"
(( $# > 0 )) && shift
########### BASE CONFIG ###########
basic_options=(--write-info-json -i --download-archive downloaded.txt)
downloader_options=(--external-downloader aria2c --downloader aria2c --extractor-args --external-downloader-args aria2c:"-j 12 -s 12 -x 12 -k 5M")
output_options=(-o '%(upload_date)s-%(title).64s')
extra_options=()
if [[ -z "$config_file" ]]
then
config_file="./.dlconfig.zsh"
fi
if [[ -d "$config_file" ]]
then
config_file="$config_file/.dlconfig.zsh"
fi
source $config_file
yt-dlp ${basic_options} ${downloader_options} ${output_options} ${extra_options} "$TARGET"--write-info-jsonをつけているけど、これはこのプログラムの製作中に追加したもので、プログラムはこれがないことを想定して作っている。
明らかに--write-info-jsonしたほうが良い。
プレイリストを編成する
プレイリストをダウンロードし、しかしプレイリストの動画のダウンロードはせず、プレイリストの動画のメタデータ一覧を獲得する。
それに使えるのが--dump-jsonオプションだ。
--dump-jsonオプションはデフォルトで動画をダウンロードせず、1行ごとに動画のJSONメタデータを出力する。
--download-archiveを使うことで未ダウンロードのものを獲得する。
#!/bin/zsh
for i in */target
do
(
print "============================>>>> "${i:h}
cd ${i:h}
yt-dlp --dump-json -i --download-archive downloaded.txt --external-downloader aria2c --downloader aria2c --extractor-args --external-downloader-args aria2c:"-j 12 -s 12 -x 12 -k 5M" -i -o '%(upload_date)s-%(title).64s' $(<target) | ruby ../pudlmeta.rb
)
donerudlmeta.rbは出力されたメタデータを再編する。
#!/bin/ruby
require 'json'
transfar = []
ARGF.each do |line|
meta = JSON.load(line)
transfar.push({
"id" => meta["id"],
"filename" => meta["filename"],
"ext" => meta["ext"]
})
end
unless transfar.empty?
File.open("transfar.json", "w") do |f|
JSON.dump(transfar, f)
end
endこれでプレイリストの中で動画がないものを列挙したtransfar.jsonが準備できる。
アーカイブから動画を取得する
メタデータから百発百中で動画を獲得できれば良いのだが、実際はそうはいかない。 同じオプションを与えたとしても、動画ファイルが異なるものになったりするからだ。
理由はよくわからないが、upload_dateが違う日になったりするし、タイトルを使っていると概要が編集されてしまったりもする。
IDを使えば確実だが、そのためには前提としてIDで構成するようにしていないといけない。
そのため、ファイル名のsimilarityに基づいて判定する。
RubyのGem string-similarityを使う。
想定されるファイル名と同一のファイル名が存在するのであればそれで良い。
そうでない場合、ファイル名が近ければ(例えば日付だけ変わったようなことで)同一だとみなしていいだろう。
ただし、#1のような動画ナンバリングだけが異なる動画をアップロードしているチャンネルではないことが前提として必要になる。
閾値は実際に取り扱っているチャンネルに基づいて決めている。
また、前提としてアーカイブが$日付-$タイトル.$拡張子であることを忘れないように。
近似ファイルの検索は、まず日付が同じものに絞って行っている。 全リストだと日付が遠く離れたものに引きずられやすいのと、ファイル数が多いと時間がかかりすぎるためだ。 それで見つからなかった場合は全ファイルに対象を広げる。
存在するのであればIDベースのメタデータファイルを利用する。
そして、同一だとみなせるものはコピーしていく。
rsyncでコピーしているが、これはプレイリストをコンパイルしたものは別ファイルシステムにあるためで、通常はハードリンクにしたほうが良いだろう。
同一だとみなせるものがないのなら、missing.jsonに記録する。
downloaded.txtへの記録は、youtubeにしているが、これは対象プラグイン(=対象サイト)による。
#!/bin/ruby
require 'string/similarity'
require 'json'
superdir = ARGV.shift
filelist_map = {}
filelist = []
Dir.children(superdir).each {|i|
b = File.basename(i, ".*")
filelist_map[b] = i
filelist.push b
}
missing = {}
copylist = []
t = JSON.load File.read("transfar.json")
downloaded = File.read("#{superdir}/downloaded.txt").split("\n")
stored_meta = (Dir.children("#{superdir}/.meta") rescue [])
t.each do |i|
if stored_meta.include("#{i["id"]}.json")
meta = JSON.load(File.read "#{i["id"]}.json")
copylist.push({
requested: i,
found: {
file_full: meta["filename"],
}
})
else
sub = filelist.select {|f| f[0,8] == i["filename"][0,8] }
rf = i["filename"].gsub("/", "_").gsub("⧸", "_")
subsim = sub.map {|x| {file: x, score: String::Similarity.cosine(x, rf), req: rf} }
best = subsim.max_by {|x| x[:score] }
if best[:score] < 0.97
most = filelist.max_by {|x| String::Similarity.cosine(x, rf) }
if (ms = String::Similarity.cosine(most, rf)) < 0.99
missing[i["id"]] = {
requested: i,
found: best,
found_real: filelist_map[best[:file]],
archived: downloaded.include?("youtube #{i["id"]}"),
most: most,
most_score: ms
}
else
copylist.push({
requested: i,
found: best
})
end
else
copylist.push({
requested: i,
found: best
})
end
end
end
File.open("downloaded.txt", "a") do |f|
copylist.each do |i|
system("rsync", "--ignore-existing", "-v", "#{superdir}/#{i[:found][:file_full] || filelist_map[i[:found][:file]]}", "./")
f.puts("youtube #{i[:requested]["id"]}")
end
end
unless missing.empty?
missing_data = (JSON.load(File.read("missing.json")) rescue {})
missing_data.merge! missing
File.open("missing.json", "w") {|f| JSON.dump(missing, f)}
end一致するものが見つけられなかった場合
missing.jsonにデータが残っているので、これを手動で拾い上げていく。
十分な近似性が見つけられなかったものの、もっとも有力だと思われるものは記録してあるので、それが同じかどうかを対話的に判定する。
同一だった場合、コピーしてdownloaded.txtに追加すれば良い。
同一でなかった場合、当該動画がアーカイブにないのだろう。アーカイブ側のdownloaded.txtに(動画がダウンロードできていないにも関わらず)存在しているのなら消去する。
#!/bin/ruby
require 'json'
superdir = ARGV.shift
abort unless superdir
missing = JSON.load File.read "missing.json"
archived = File.read("#{superdir}/downloaded.txt").split("\n")
archived_orig = archived.dup
File.open("downloaded.txt", "a") do |f|
missing.each do |k, v|
puts "R: #{v["requested"]["filename"]}"
puts "M: #{v["found"]["file"]}"
loop do
print "Is it same? "
input = gets.chomp
if input[0].downcase == "y"
system("rsync", "--ignore-existing", "-v", "#{superdir}/#{v["found_real"]}", "./")
f.puts("youtube #{k}")
break
elsif input[0].downcase == "n"
archived.delete("youtube #{k}")
break
else
nil
end
end
end
end
if archived != archived_orig
File.open("#{superdir}/downloaded.txt", "w") {|f| f.puts archived.join("\n") }
endこれで一致しなかったものについては、一旦忘れるで良い。
アーカイブ側をまたダウンロードすれば、downloaded.txtからは消してあるので、ダウンロードされるだろう。
IDベースのメタデータを編成する
--write-info-jsonを使って作ったメタデータを、IDで引けるようにする。
単にリンクでも良いのだが、そうすると対応するファイルを引けなくなるため、それだけ追加する。
#!/bin/ruby
require 'json'
json_files = Dir.children(".").select {|i| i[-10, 10] == ".info.json" }
json_files.each do |jf|
meta = JSON.load File.read jf
fn = File.basename(jf, ".info.json") + "." + (meta["ext"] || "")
next unless File.exist?(fn)
new_meta = { "meta" => meta, "filename" => fn }
File.open(".meta/#{meta["id"]}.json", "w") {|f| JSON.dump(new_meta, f) }
end