Helper tool for stitching together livestream VOD segments and uploading them to YouTube!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

first working version!

+678 -69
+1
.gitignore
··· 1 + .DS_Store 1 2 config.toml
+10 -3
go.mod
··· 3 3 go 1.25.3 4 4 5 5 require ( 6 + github.com/pelletier/go-toml/v2 v2.2.4 7 + golang.org/x/oauth2 v0.32.0 8 + google.golang.org/api v0.254.0 9 + ) 10 + 11 + require ( 6 12 cloud.google.com/go/auth v0.17.0 // indirect 7 13 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 8 14 cloud.google.com/go/compute/metadata v0.9.0 // indirect 15 + github.com/aws/aws-sdk-go v1.38.20 // indirect 9 16 github.com/felixge/httpsnoop v1.0.4 // indirect 10 17 github.com/go-logr/logr v1.4.3 // indirect 11 18 github.com/go-logr/stdr v1.2.2 // indirect ··· 13 20 github.com/google/uuid v1.6.0 // indirect 14 21 github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 15 22 github.com/googleapis/gax-go/v2 v2.15.0 // indirect 16 - github.com/pelletier/go-toml/v2 v2.2.4 // indirect 23 + github.com/jmespath/go-jmespath v0.4.0 // indirect 24 + github.com/u2takey/ffmpeg-go v0.5.0 // indirect 25 + github.com/u2takey/go-utils v0.3.1 // indirect 17 26 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 18 27 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 19 28 go.opentelemetry.io/otel v1.37.0 // indirect ··· 21 30 go.opentelemetry.io/otel/trace v1.37.0 // indirect 22 31 golang.org/x/crypto v0.43.0 // indirect 23 32 golang.org/x/net v0.46.0 // indirect 24 - golang.org/x/oauth2 v0.32.0 // indirect 25 33 golang.org/x/sys v0.37.0 // indirect 26 34 golang.org/x/text v0.30.0 // indirect 27 - google.golang.org/api v0.254.0 // indirect 28 35 google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect 29 36 google.golang.org/grpc v1.76.0 // indirect 30 37 google.golang.org/protobuf v1.36.10 // indirect
+77
go.sum
··· 4 4 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 5 5 cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= 6 6 cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 7 + github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= 8 + github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= 9 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 + github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 7 13 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 8 14 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 15 + github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 16 + github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= 9 17 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 10 18 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 11 19 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 12 20 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 13 21 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 22 + github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= 23 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 24 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 25 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 26 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 27 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 14 28 github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 15 29 github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 30 + github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 16 31 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 17 32 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 18 33 github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= 19 34 github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= 20 35 github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= 21 36 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= 37 + github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 38 + github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 39 + github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 40 + github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 41 + github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 42 + github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 43 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 44 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 45 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 46 + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 47 + github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 48 + github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= 22 49 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 23 50 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 51 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 52 + github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 53 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 + github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 56 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 57 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 58 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 59 + github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 60 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 61 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 62 + github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= 63 + github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= 64 + github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= 65 + github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= 24 66 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 25 67 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 26 68 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= ··· 29 71 go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 30 72 go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 31 73 go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 74 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 75 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 76 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 77 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 32 78 go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 33 79 go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 80 + gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= 81 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 82 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 34 83 golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 35 84 golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 85 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 86 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 87 + golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 36 88 golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 37 89 golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 38 90 golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= 39 91 golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 92 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 93 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 94 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 95 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 96 + golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 97 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 98 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 41 99 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 100 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 101 + golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 102 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 42 103 golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 43 104 golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 105 + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 106 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 107 + golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 108 + gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 109 + gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 44 110 google.golang.org/api v0.254.0 h1:jl3XrGj7lRjnlUvZAbAdhINTLbsg5dbjmR90+pTQvt4= 45 111 google.golang.org/api v0.254.0/go.mod h1:5BkSURm3D9kAqjGvBNgf0EcbX6Rnrf6UArKkwBzAyqQ= 112 + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= 113 + google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= 114 + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= 115 + google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= 46 116 google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= 47 117 google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= 48 118 google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= 49 119 google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= 50 120 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 51 121 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 122 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 123 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 124 + gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 125 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 126 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 127 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 128 + sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
+185 -66
main.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "log" 7 8 "os" 9 + "path" 10 + "strings" 8 11 9 12 toml "github.com/pelletier/go-toml/v2" 10 - "google.golang.org/api/option" 13 + "golang.org/x/oauth2" 14 + "golang.org/x/oauth2/google" 11 15 "google.golang.org/api/youtube/v3" 16 + 17 + "arimelody.space/live-vod-uploader/scanner" 18 + vid "arimelody.space/live-vod-uploader/video" 19 + yt "arimelody.space/live-vod-uploader/youtube" 12 20 ) 13 21 14 22 type ( ··· 23 31 } 24 32 ) 25 33 26 - var DEFAULT_TAGS = []string{ 27 - "ari melody", 28 - "ari melody LIVE", 29 - "livestream", 30 - "vtuber", 31 - "twitch", 32 - "gaming", 33 - "let's play", 34 - "full VOD", 35 - "VOD", 36 - "stream", 37 - "archive", 38 - } 34 + const CONFIG_FILENAME = "config.toml" 39 35 40 - const ( 41 - CATEGORY_GAMING = "20" 42 - ) 36 + func showHelp() { 37 + execSplits := strings.Split(os.Args[0], "/") 38 + execName := execSplits[len(execSplits) - 1] 39 + fmt.Printf( 40 + "usage: %s [options] [directory]\n\n" + 41 + "options:\n" + 42 + "\t-h, --help: Show this help message.\n" + 43 + "\t-v, --verbose: Show verbose logging output.\n" + 44 + "\t--init: Initialise `directory` as a VOD directory.\n", 45 + execName) 46 + } 43 47 44 48 func main() { 45 - if len(os.Args) < 2 { 46 - fmt.Printf("usage: %s <video ID>\n", os.Args[0]) 49 + if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { 50 + showHelp() 47 51 os.Exit(0) 48 52 } 49 53 50 - // videoID := os.Args[1] 54 + var directory string 55 + var initDirectory bool = false 56 + var verbose bool = false 57 + 58 + for i, arg := range os.Args { 59 + if i == 0 { continue } 60 + if strings.HasPrefix(arg, "-") { 61 + switch arg { 62 + 63 + case "-h": 64 + fallthrough 65 + case "--help": 66 + showHelp() 67 + os.Exit(0) 51 68 52 - cfgBytes, err := os.ReadFile("config.toml") 53 - if err != nil { 54 - fmt.Fprintf(os.Stderr, "fatal: failed to read config file: %s\n", err.Error()) 55 - os.Exit(1) 69 + case "--init": 70 + initDirectory = true 71 + 72 + case "-v": 73 + fallthrough 74 + case "--verbose": 75 + verbose = true 76 + 77 + default: 78 + fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) 79 + os.Exit(1) 80 + 81 + } 82 + } else { 83 + directory = arg 84 + } 56 85 } 86 + 57 87 cfg := Config{} 88 + cfgBytes, err := os.ReadFile(CONFIG_FILENAME) 89 + if err != nil { 90 + log.Fatalf("Failed to read config file: %v", err) 91 + 92 + tomlBytes, err := toml.Marshal(&cfg) 93 + if err != nil { 94 + log.Fatalf("Failed to marshal json: %v", err) 95 + os.Exit(1) 96 + } 97 + 98 + err = os.WriteFile(CONFIG_FILENAME, tomlBytes, 0o644) 99 + if err != nil { 100 + log.Fatalf("Failed to write config file: %v", err) 101 + os.Exit(1) 102 + } 103 + 104 + log.Printf("New config file created. Please edit this before running again!") 105 + os.Exit(0) 106 + } 58 107 err = toml.Unmarshal(cfgBytes, &cfg) 59 108 if err != nil { 60 - fmt.Fprintf(os.Stderr, "fatal: failed to parse config: %s\n", err.Error()) 109 + log.Fatalf("Failed to parse config: %v", err) 61 110 os.Exit(1) 62 111 } 63 112 64 - ctx := context.Background() 65 - service, err := youtube.NewService( 66 - ctx, 67 - option.WithScopes(youtube.YoutubeUploadScope), 68 - option.WithAPIKey(cfg.Google.ApiKey), 69 - ) 113 + if initDirectory { 114 + dirInfo, err := os.Stat(directory) 115 + if err != nil { 116 + if err == os.ErrNotExist { 117 + log.Fatalf("No such directory: %s", directory) 118 + os.Exit(1) 119 + } 120 + log.Fatalf("Failed to open directory: %v", err) 121 + os.Exit(1) 122 + } 123 + if !dirInfo.IsDir() { 124 + log.Fatalf("Not a directory: %s", directory) 125 + os.Exit(1) 126 + } 127 + dirEntry, err := os.ReadDir(directory) 128 + if err != nil { 129 + log.Fatalf("Failed to open directory: %v", err) 130 + os.Exit(1) 131 + } 132 + for _, entry := range dirEntry { 133 + if !entry.IsDir() && entry.Name() == "metadata.toml" { 134 + log.Printf("Directory `%s` already initialised", directory) 135 + os.Exit(0) 136 + return 137 + } 138 + 139 + defaultMetadata := scanner.DefaultMetadata() 140 + metadataStr, _ := toml.Marshal(defaultMetadata) 141 + err = os.WriteFile(path.Join(directory, "metadata.toml"), metadataStr, 0o644) 142 + if err != nil { 143 + log.Fatalf("Failed to write to file: %v", err) 144 + os.Exit(1) 145 + } 146 + log.Printf("Directory successfully initialised") 147 + os.Exit(0) 148 + } 149 + } 150 + 151 + metadata, err := scanner.FetchMetadata(directory) 70 152 if err != nil { 71 - fmt.Fprintf(os.Stderr, "fatal: failed to create youtube service: %s\n", err.Error()) 153 + log.Fatalf("Failed to fetch VOD metadata: %v", err) 154 + os.Exit(1) 155 + } 156 + if metadata == nil { 157 + log.Fatal("Directory contained no metadata. Use `--init` to initialise this directory.") 158 + os.Exit(1) 159 + } 160 + vodFiles, err := scanner.FetchVideos(metadata.FootageDir) 161 + if err != nil { 162 + log.Fatalf("Failed to fetch VOD filenames: %v", err) 163 + os.Exit(1) 164 + } 165 + if len(vodFiles) == 0 { 166 + log.Fatal("Directory contained no VOD files (expecting .mkv)") 72 167 os.Exit(1) 73 168 } 74 169 75 - videoService := youtube.NewVideosService(service) 170 + if verbose { 171 + enc := json.NewEncoder(os.Stdout) 172 + enc.SetIndent("", "\t") 173 + fmt.Printf("Directory metadata: ") 174 + enc.Encode(metadata) 175 + fmt.Printf("\nVOD files available: ") 176 + enc.Encode(vodFiles) 177 + } 76 178 77 - // get video by ID 78 - { 79 - // call := service.Videos.List([]string{ 80 - // "snippet", "contentDetails", "statistics", "status", 81 - // }).Id(videoID) 82 - // res, err := call.Do() 83 - // if err != nil { 84 - // fmt.Fprintf(os.Stderr, "fatal: failed to request videos list: %s\n", err.Error()) 85 - // os.Exit(1) 86 - // } 179 + video, err := yt.BuildVideo(metadata) 180 + if err != nil { 181 + log.Fatalf("Failed to build video template: %v", err) 182 + os.Exit(1) 183 + } 184 + if verbose { 185 + enc := json.NewEncoder(os.Stdout) 186 + fmt.Printf("\nVideo template: ") 187 + enc.Encode(video) 87 188 88 - // data, err := json.MarshalIndent(res, "", " ") 89 - // if err != nil { 90 - // fmt.Fprintf(os.Stderr, "fatal: failed to marshal json: %s\n", err.Error()) 91 - // os.Exit(1) 92 - // } 93 - 94 - // fmt.Println(string(data)) 189 + title, err := yt.BuildTitle(video) 190 + if err != nil { 191 + log.Fatalf("Failed to build video title: %v", err) 192 + os.Exit(1) 193 + } 194 + description, err := yt.BuildDescription(video) 195 + if err != nil { 196 + log.Fatalf("Failed to build video description: %v", err) 197 + os.Exit(1) 198 + } 199 + fmt.Printf( 200 + "\nTITLE: %s\nDESCRIPTION: %s", 201 + title, description, 202 + ) 95 203 } 96 204 97 - call := videoService.Insert([]string{ 98 - "snippet", "status", 99 - }, &youtube.Video{ 100 - Snippet: &youtube.VideoSnippet{ 101 - Title: "Untitled Video", 102 - Description: "No description", 103 - Tags: DEFAULT_TAGS, 104 - CategoryId: CATEGORY_GAMING, // gaming 105 - }, 106 - }).NotifySubscribers(false) 107 - // TODO: call.Media() 108 - video, err := call.Do() 205 + err = vid.ConcatVideo(video, vodFiles) 109 206 if err != nil { 110 - fmt.Fprintf(os.Stderr, "fatal: failed to upload video: %s\n", err.Error()) 207 + log.Fatalf("Failed to concatenate VOD files: %v", err) 111 208 os.Exit(1) 112 209 } 113 210 114 - data, err := json.MarshalIndent(video, "", " ") 211 + // okay actual youtube stuff now 212 + 213 + // TODO: tidy up oauth flow with localhost webserver 214 + ctx := context.Background() 215 + config := &oauth2.Config{ 216 + ClientID: cfg.Google.ClientID, 217 + ClientSecret: cfg.Google.ClientSecret, 218 + Endpoint: google.Endpoint, 219 + Scopes: []string{ youtube.YoutubeScope }, 220 + RedirectURL: "http://localhost:8090", 221 + } 222 + verifier := oauth2.GenerateVerifier() 223 + url := config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) 224 + log.Printf("Visit URL to initiate OAuth2: %s", url) 225 + 226 + var code string 227 + fmt.Print("Enter OAuth2 code: ") 228 + if _, err := fmt.Scan(&code); err != nil { 229 + log.Fatalf("Failed to read oauth2 code: %v", err) 230 + } 231 + 232 + token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier)) 233 + log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) 115 234 if err != nil { 116 - fmt.Fprintf(os.Stderr, "fatal: failed to marshal video data json: %s\n", err.Error()) 235 + log.Fatalf("Could not exchange OAuth2 code: %v", err) 117 236 os.Exit(1) 118 237 } 119 238 120 - fmt.Println(string(data)) 239 + yt.UploadVideo(ctx, token, video) 121 240 }
+90
scanner/scanner.go
··· 1 + package scanner 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "time" 8 + 9 + "github.com/pelletier/go-toml/v2" 10 + ) 11 + 12 + type ( 13 + Category struct { 14 + Name string 15 + Type string 16 + Url string 17 + } 18 + 19 + Metadata struct { 20 + Title string 21 + Date string 22 + Part int 23 + FootageDir string 24 + Category *Category 25 + } 26 + ) 27 + 28 + func FetchVideos(directory string) ([]string, error) { 29 + entries, err := os.ReadDir(directory) 30 + if err != nil { 31 + return nil, err 32 + } 33 + 34 + files := []string{} 35 + 36 + for _, item := range entries { 37 + if item.IsDir() { continue } 38 + if !strings.HasSuffix(item.Name(), ".mkv") { continue } 39 + files = append(files, item.Name()) 40 + } 41 + 42 + return files, nil 43 + } 44 + 45 + func FetchMetadata(directory string) (*Metadata, error) { 46 + entries, err := os.ReadDir(directory) 47 + if err != nil { 48 + return nil, err 49 + } 50 + 51 + for _, item := range entries { 52 + if item.IsDir() { continue } 53 + if item.Name() == "metadata.toml" { 54 + metadata, err := ParseMetadata(filepath.Join(directory, item.Name())) 55 + if err != nil { 56 + return nil, err 57 + } 58 + metadata.FootageDir = filepath.Join(directory, metadata.FootageDir) 59 + return metadata, nil 60 + } 61 + } 62 + 63 + return nil, nil 64 + } 65 + 66 + func ParseMetadata(filename string) (*Metadata, error) { 67 + metadata := &Metadata{} 68 + file, err := os.OpenFile(filename, os.O_RDONLY, 0o644) 69 + if err != nil { 70 + return nil, err 71 + } 72 + err = toml.NewDecoder(file).Decode(metadata) 73 + if err != nil { 74 + return nil, err 75 + } 76 + return metadata, nil 77 + } 78 + 79 + func DefaultMetadata() Metadata { 80 + return Metadata{ 81 + Title: "Untitled Stream", 82 + Date: time.Now().Format("2006-01-02"), 83 + Part: 0, 84 + Category: &Category{ 85 + Name: "Something", 86 + Type: "", 87 + Url: "", 88 + }, 89 + } 90 + }
+17
template/description.txt
··· 1 + streamed on {{.Date}} 2 + 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 3 + {{if .Title}}{{if eq .Title.Type "game"}} 4 + 🎮 play {{.Title.Name}}: 5 + {{.Title.Url}} 6 + {{else}} 7 + ✨ check out {{.Title.Name}}: 8 + {{.Title.Url}} 9 + {{end}}{{end}} 10 + 💫 ari's place: https://arimelody.space 11 + 💬 ari melody discord: https://arimelody.space/discord 12 + 13 + 🎵 intro music: 14 + Mameyudoufu - Second Brain 15 + https://www.youtube.com/watch?v=5leDpfJLzLU&list=OLAK5uy_nuYS1Q3Bj8DkuJq-ylYmBZHetepavg0lI 16 + 17 + 🥰 i hope you're having a lovely day!
+1
template/title.txt
··· 1 + {{.Title.Name}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}}
+66
video/video.go
··· 1 + package video 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path" 8 + "strconv" 9 + 10 + "arimelody.space/live-vod-uploader/youtube" 11 + ffmpeg "github.com/u2takey/ffmpeg-go" 12 + ) 13 + 14 + type ( 15 + probeFormat struct { 16 + Duration string `json:"duration"` 17 + } 18 + probeData struct { 19 + Format probeFormat `json:"format"` 20 + } 21 + ) 22 + 23 + func ConcatVideo(video *youtube.Video, vodFiles []string) error { 24 + fileListPath := path.Join( 25 + path.Dir(video.Filename), 26 + "files.txt", 27 + ) 28 + 29 + totalDuration := float64(0.0) 30 + fileListString := "" 31 + for _, file := range vodFiles { 32 + fileListString += fmt.Sprintf("file '%s'\n", file) 33 + jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) 34 + if err != nil { 35 + return fmt.Errorf("failed to probe file `%s`: %v", file, err) 36 + } 37 + probe := probeData{} 38 + json.Unmarshal([]byte(jsonProbe), &probe) 39 + duration, err := strconv.ParseFloat(probe.Format.Duration, 64) 40 + if err != nil { 41 + return fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) 42 + } 43 + totalDuration += duration 44 + } 45 + err := os.WriteFile( 46 + fileListPath, 47 + []byte(fileListString), 48 + 0o644, 49 + ) 50 + if err != nil { 51 + return fmt.Errorf("failed to write file list: %v", err) 52 + } 53 + 54 + err = ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ 55 + "f": "concat", 56 + "safe": "0", 57 + }).Output(video.Filename, ffmpeg.KwArgs{ 58 + "c": "copy", 59 + }).OverWriteOutput().ErrorToStdOut().Run() 60 + 61 + if err != nil { 62 + return fmt.Errorf("ffmpeg error: %v", err) 63 + } 64 + 65 + return nil 66 + }
+231
youtube/youtube.go
··· 1 + package youtube 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log" 9 + "os" 10 + "path" 11 + "strings" 12 + "text/template" 13 + "time" 14 + 15 + "arimelody.space/live-vod-uploader/scanner" 16 + "golang.org/x/oauth2" 17 + "google.golang.org/api/option" 18 + "google.golang.org/api/youtube/v3" 19 + ) 20 + 21 + var DEFAULT_TAGS = []string{ 22 + "ari melody", 23 + "ari melody LIVE", 24 + "livestream", 25 + "vtuber", 26 + "twitch", 27 + "gaming", 28 + "let's play", 29 + "full VOD", 30 + "VOD", 31 + "stream", 32 + "archive", 33 + } 34 + 35 + const ( 36 + CATEGORY_GAMING = "20" 37 + CATEGORY_ENTERTAINMENT = "24" 38 + ) 39 + 40 + type TitleType int 41 + const ( 42 + TITLE_GAME TitleType = iota 43 + TITLE_OTHER 44 + ) 45 + 46 + type ( 47 + Title struct { 48 + Name string 49 + Type TitleType 50 + Url string 51 + } 52 + 53 + Video struct { 54 + Title *Title 55 + Part int 56 + Date time.Time 57 + Tags []string 58 + Filename string 59 + } 60 + ) 61 + 62 + func BuildVideo(metadata *scanner.Metadata) (*Video, error) { 63 + var titleType TitleType 64 + switch metadata.Category.Type { 65 + case "gaming": 66 + titleType = TITLE_GAME 67 + default: 68 + titleType = TITLE_OTHER 69 + } 70 + 71 + videoDate, err := time.Parse("2006-01-02", metadata.Date) 72 + if err != nil { 73 + return nil, fmt.Errorf("failed to parse date from metadata: %v", err) 74 + } 75 + 76 + return &Video{ 77 + Title: &Title{ 78 + Name: metadata.Category.Name, 79 + Type: titleType, 80 + Url: metadata.Category.Url, 81 + }, 82 + Part: metadata.Part, 83 + Date: videoDate, 84 + Tags: DEFAULT_TAGS, 85 + Filename: path.Join( 86 + metadata.FootageDir, 87 + fmt.Sprintf( 88 + "%s-fullvod.mkv", 89 + videoDate.Format("2006-01-02"), 90 + )), 91 + }, nil 92 + } 93 + 94 + type ( 95 + MetaTitle struct { 96 + Name string 97 + Type string 98 + Url string 99 + } 100 + 101 + Metadata struct { 102 + Date string 103 + Title *MetaTitle 104 + Part int 105 + } 106 + ) 107 + 108 + var titleTemplate *template.Template = template.Must( 109 + template.ParseFiles("template/title.txt"), 110 + ) 111 + func BuildTitle(video *Video) (string, error) { 112 + var titleType string 113 + switch video.Title.Type { 114 + case TITLE_GAME: 115 + titleType = "game" 116 + case TITLE_OTHER: 117 + fallthrough 118 + default: 119 + titleType = "other" 120 + } 121 + 122 + out := &bytes.Buffer{} 123 + titleTemplate.Execute(out, Metadata{ 124 + Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 125 + Title: &MetaTitle{ 126 + Name: video.Title.Name, 127 + Type: titleType, 128 + Url: video.Title.Url, 129 + }, 130 + Part: video.Part, 131 + }) 132 + 133 + return strings.TrimSpace(out.String()), nil 134 + } 135 + 136 + var descriptionTemplate *template.Template = template.Must( 137 + template.ParseFiles("template/description.txt"), 138 + ) 139 + func BuildDescription(video *Video) (string, error) { 140 + var titleType string 141 + switch video.Title.Type { 142 + case TITLE_GAME: 143 + titleType = "game" 144 + case TITLE_OTHER: 145 + fallthrough 146 + default: 147 + titleType = "other" 148 + } 149 + 150 + out := &bytes.Buffer{} 151 + descriptionTemplate.Execute(out, Metadata{ 152 + Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 153 + Title: &MetaTitle{ 154 + Name: video.Title.Name, 155 + Type: titleType, 156 + Url: video.Title.Url, 157 + }, 158 + Part: video.Part, 159 + }) 160 + 161 + return out.String(), nil 162 + } 163 + 164 + func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) error { 165 + title, err := BuildTitle(video) 166 + if err != nil { 167 + return fmt.Errorf("failed to build title: %v", err) 168 + } 169 + description, err := BuildDescription(video) 170 + if err != nil { 171 + return fmt.Errorf("failed to build description: %v", err) 172 + } 173 + 174 + service, err := youtube.NewService( 175 + ctx, 176 + option.WithScopes(youtube.YoutubeUploadScope), 177 + option.WithTokenSource(oauth2.StaticTokenSource(token)), 178 + ) 179 + if err != nil { 180 + log.Fatalf("Failed to create youtube service: %v\n", err) 181 + return err 182 + } 183 + 184 + videoService := youtube.NewVideosService(service) 185 + 186 + var categoryId string 187 + switch video.Title.Type { 188 + case TITLE_GAME: 189 + categoryId = CATEGORY_GAMING 190 + default: 191 + categoryId = CATEGORY_ENTERTAINMENT 192 + } 193 + 194 + call := videoService.Insert([]string{ 195 + "snippet", "status", 196 + }, &youtube.Video{ 197 + Snippet: &youtube.VideoSnippet{ 198 + Title: title, 199 + Description: description, 200 + Tags: append(DEFAULT_TAGS, video.Tags...), 201 + CategoryId: categoryId, // gaming 202 + }, 203 + Status: &youtube.VideoStatus{ 204 + PrivacyStatus: "private", 205 + }, 206 + }).NotifySubscribers(false) 207 + 208 + file, err := os.Open(video.Filename) 209 + if err != nil { 210 + log.Fatalf("Failed to open file: %v\n", err) 211 + return err 212 + } 213 + call.Media(file) 214 + 215 + log.Println("Uploading video...") 216 + 217 + res, err := call.Do() 218 + if err != nil { 219 + log.Fatalf("Failed to upload video: %v\n", err) 220 + return err 221 + } 222 + 223 + data, err := json.MarshalIndent(res, "", " ") 224 + if err != nil { 225 + log.Fatalf("Failed to marshal video data json: %v\n", err) 226 + return err 227 + } 228 + 229 + fmt.Println(string(data)) 230 + return nil 231 + }