Skip to content

Instantly share code, notes, and snippets.

@YujiSoftware
Created May 8, 2025 14:24
Show Gist options
  • Save YujiSoftware/40b3cf177c7ae891c20b7355afbd1815 to your computer and use it in GitHub Desktop.
Save YujiSoftware/40b3cf177c7ae891c20b7355afbd1815 to your computer and use it in GitHub Desktop.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/youtube/v3"
)
// トークン取得(キャッシュ or 認可フロー)
func getClient() *http.Client {
// credentials.json はOAuthクライアントIDを含むJSONファイル
b, err := os.ReadFile("credentials.json")
if err != nil {
panic(fmt.Sprintf("Unable to read client secret file: %v", err))
}
config, err := google.ConfigFromJSON(b, youtube.YoutubeScope)
if err != nil {
panic(fmt.Sprintf("Unable to parse client secret file to config: %v", err))
}
tokenFile := "youtube-token.json"
tok, err := tokenFromFile(tokenFile)
if err != nil {
tok = getTokenFromWeb(config)
saveToken(tokenFile, tok)
}
return config.Client(context.Background(), tok)
}
// トークンをファイルに保存
func saveToken(path string, token *oauth2.Token) {
fmt.Printf("Saving credential file to: %s\n", path)
f, err := os.Create(path)
if err != nil {
panic(fmt.Sprintf("Unable to cache oauth token: %v", err))
}
defer f.Close()
if err := json.NewEncoder(f).Encode(token); err != nil {
panic(fmt.Sprintf("Unable to encode oauth token: %v", err))
}
}
// 保存済みトークンを読み込み
func tokenFromFile(file string) (*oauth2.Token, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
tok := &oauth2.Token{}
err = json.NewDecoder(f).Decode(tok)
return tok, err
}
// ブラウザで認可 → 認可コード入力
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Open the following link in your browser then type the authorization code:\n%v\n", authURL)
var code string
fmt.Print("Enter authorization code: ")
fmt.Scan(&code)
tok, err := config.Exchange(context.TODO(), code)
if err != nil {
panic(fmt.Sprintf("Unable to retrieve token from web: %v", err))
}
return tok
}
package database
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
type LinkType int
const (
LinkWriter LinkType = iota
LinkComposer
LinkArranger
LinkVocal
)
type Connection struct {
db *sql.DB
tx *sql.Tx
stmt struct {
list *sql.Stmt
}
}
func Open(file string) *Connection {
var c Connection
var err error
c.db, err = sql.Open("sqlite3", file)
if err != nil {
panic(err)
}
c.tx, err = c.db.Begin()
if err != nil {
panic(err)
}
c.stmt.list, err = c.tx.Prepare(`
SELECT
youtube_id
FROM (
SELECT
FIRST_VALUE(y.id) over (PARTITION BY page_id ORDER BY view_count DESC) AS youtube_id
, RANK() over (PARTITION BY page_id ORDER BY view_count DESC) AS rank
FROM
page_youtube p
INNER JOIN youtube y ON p.youtube_id = y.id
WHERE
p.page_id IN (SELECT page_id FROM music WHERE year = ?)
)
WHERE
rank = 1
;`)
if err != nil {
panic(err)
}
return &c
}
func (c *Connection) GetList(year int) []string {
rows, err := c.stmt.list.Query(year)
if err != nil {
panic(err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
panic(err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
panic(err)
}
return ids
}
func (c *Connection) Rollback() {
if err := c.tx.Rollback(); err != nil {
panic(err)
}
}
func (c *Connection) Commit() {
if err := c.tx.Commit(); err != nil {
panic(err)
}
}
func (c *Connection) Close() {
if err := c.db.Close(); err != nil {
panic(err)
}
}
module yuji.software/hmiku/playlist
go 1.23.4
require (
github.com/mattn/go-sqlite3 v1.14.28
google.golang.org/api v0.231.0
)
require (
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.29.0
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197 // indirect
google.golang.org/grpc v1.72.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"slices"
"time"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
"yuji.software/hmiku/playlist/database"
)
const (
start = 2007
end = 2025
)
var playlistID = map[int]string{
2007: "PLLOGbFuoZYK1kVq50vno4dNiUqcnn2JqN",
2008: "PLLOGbFuoZYK0EY6lBuQZSrCMxxZWcA4sp",
2009: "PLLOGbFuoZYK2hZAqhy1ehex68Gy6L3Bax",
2010: "PLLOGbFuoZYK2-TZUtQMkZ9iW0GuUKNgt-",
2011: "PLLOGbFuoZYK0RwyOpQ7yLUwd9XMY8uSSA",
2012: "PLLOGbFuoZYK0pO662FpYaVxM9qBlzxwCd",
2013: "PLLOGbFuoZYK1WEJd2z0Ij-8jABBum1QJN",
2014: "PLLOGbFuoZYK28Mv5GTAqtBTH5ptekZHHr",
2015: "PLLOGbFuoZYK0WaVaNmYgkZqllw3mpWCIL",
2016: "PLLOGbFuoZYK1kJ5d8UWqIWM83j61YP4er",
2017: "PLLOGbFuoZYK1nY5XwNhlGmUTcJS3fLXaK",
2018: "PLLOGbFuoZYK1cACqE5YqqfbAZZb6NV5jP",
2019: "PLLOGbFuoZYK1Vgvono9f67MjRKVsNjRew",
2020: "PLLOGbFuoZYK3dpNALJ8C5JxetSoo9m7yC",
2021: "PLLOGbFuoZYK1aTFuls0wHcOtVutJOzhn4",
2022: "PLLOGbFuoZYK2AccWft34pueiyk5Rf3FKB",
2023: "PLLOGbFuoZYK0VXnURhaluvdVwnoo7Pg4r",
2024: "PLLOGbFuoZYK3Tq_7KixjjVn83vTLsAegH",
2025: "PLLOGbFuoZYK0E0K8K3JeIN_Dzx8uwSbPJ",
}
func main() {
var (
debug = flag.Bool("debug", false, "bool flag")
)
flag.Parse()
if *debug {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
}
dbFile := flag.Arg(0)
ctx := context.Background()
client := getClient()
service, err := youtube.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
panic(err)
}
con := database.Open(dbFile)
defer con.Close()
for year := start; year <= end; year++ {
dbItems := con.GetList(year)
currentItems := getPlaylistItems(service, playlistID[year])
diff := make(map[string]struct{})
for _, item := range dbItems {
diff[item] = struct{}{}
}
for _, item := range currentItems {
if _, ok := diff[item.Snippet.ResourceId.VideoId]; ok {
// プレイリストに登録済みの動画は何もしない
delete(diff, item.Snippet.ResourceId.VideoId)
} else {
// 初音ミクWikiから削除された動画は、YouTubeのプレイリストからも削除する
slog.Info("Deleting video",
"videoId", item.Snippet.ResourceId.VideoId,
"playlistId", playlistID[year],
"playlistItemId", item.Id,
)
call := service.PlaylistItems.Delete(item.Id)
if err := call.Do(); err != nil {
panic(err)
}
}
}
if len(diff) == 0 {
continue
}
// 削除されている場合もあるので、動画情報を取得して取れたものを処理する
videoIds := make([]string, 0, len(diff))
for id := range diff {
videoIds = append(videoIds, id)
}
for _, video := range getVideos(service, videoIds) {
slog.Info("Inserting video",
"videoId", video.Id,
"playlistId", playlistID[year],
)
playlistItem := &youtube.PlaylistItem{
Snippet: &youtube.PlaylistItemSnippet{
PlaylistId: playlistID[year],
ResourceId: &youtube.ResourceId{
Kind: "youtube#video",
VideoId: video.Id,
},
},
}
call := service.PlaylistItems.Insert([]string{"snippet"}, playlistItem)
response, err := call.Do()
if err != nil {
panic(err)
}
slog.Debug("Inserted video",
"videoId", response.Snippet.ResourceId.VideoId,
"playlistId", response.Snippet.PlaylistId,
"playlistItemId", response.Id,
"position", response.Snippet.Position,
)
time.Sleep(1 * time.Second)
}
}
}
func getVideos(service *youtube.Service, videoIds []string) []*youtube.Video {
itmes := make([]*youtube.Video, 0)
for chunk := range slices.Chunk(videoIds, 50) {
call := service.Videos.List([]string{"snippet"}).Id(chunk...).MaxResults(50)
response, err := call.Do()
if err != nil {
panic(fmt.Sprintf("Error fetching videos: %v", err))
}
itmes = append(itmes, response.Items...)
}
return itmes
}
func getPlaylistItems(service *youtube.Service, playlistID string) []*youtube.PlaylistItem {
items := make([]*youtube.PlaylistItem, 0)
nextPageToken := ""
for {
call := service.PlaylistItems.List([]string{"snippet"}).
PlaylistId(playlistID).MaxResults(50)
if nextPageToken != "" {
call = call.PageToken(nextPageToken)
}
response, err := call.Do()
if err != nil {
panic(fmt.Sprintf("Error fetching items for playlist %s: %v", playlistID, err))
}
items = append(items, response.Items...)
if response.NextPageToken == "" {
break
}
nextPageToken = response.NextPageToken
}
return items
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment