Chienomi

yt-dlpでチャンネル動画をプレイリストに再編する

Live With Linux::script

本記事はグレーな要素を取り扱う。

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
  )
done

rudlmeta.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({
      i,
      {
        meta["filename"],
      }
    })
  else
    sub = filelist.select {|f| f[0,8] == i["filename"][0,8] }
    rf = i["filename"].gsub("/", "_").gsub("⧸", "_")
    subsim = sub.map {|x| {x, String::Similarity.cosine(x, rf), rf} }
    best = subsim.max_by {|x| x[] }
    if best[] < 0.97
      most = filelist.max_by {|x| String::Similarity.cosine(x, rf) }
      if (ms = String::Similarity.cosine(most, rf)) < 0.99
        missing[i["id"]] = {
          i,
          best,
          filelist_map[best[]],
          downloaded.include?("youtube #{i["id"]}"),
          most,
          ms
        }
      else
        copylist.push({
          i,
          best
        })
      end
    else
      copylist.push({
        i,
        best
      })
    end
  end
end

File.open("downloaded.txt", "a") do |f|
  copylist.each do |i|
    system("rsync", "--ignore-existing", "-v", "#{superdir}/#{i[][] || filelist_map[i[][]]}", "./")
    f.puts("youtube #{i[]["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