Created
May 8, 2025 14:24
-
-
Save YujiSoftware/40b3cf177c7ae891c20b7355afbd1815 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
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 | |
} |
This file contains hidden or 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
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) | |
} | |
} |
This file contains hidden or 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
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 | |
) |
This file contains hidden or 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
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