├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── collector ├── collector.go ├── process.go ├── scan.go └── watcher.go ├── common ├── constants │ └── cache.go ├── memcache │ └── gocache.go └── watcher │ └── watcher.go ├── config ├── config.go └── struct.go ├── example.config.json ├── ffmpeg ├── ffmpeg.go ├── ffmpeg_struct.go ├── frame.go ├── probe.go └── probe_struct.go ├── go.mod ├── go.sum ├── kodi ├── XBMC.go ├── XBMC_struct.go ├── files.go ├── files_struct.go ├── kodi.go ├── kodi_struct.go ├── limiter.go ├── video_library.go ├── video_library_clean.go ├── video_library_refresh.go ├── video_library_scan.go └── video_library_struct.go ├── main.go ├── media_file ├── media_file.go └── struct.go ├── movies ├── nfo.go ├── nfo_struct.go ├── process.go ├── process_struct.go └── tmdb.go ├── music_videos ├── collector.go ├── nfo.go ├── nfo_struct.go ├── parser.go ├── video.go ├── video_struct.go └── watcher.go ├── shows ├── nfo.go ├── nfo_struct.go ├── process.go ├── process_struct.go ├── process_test.go └── tmdb.go ├── tmdb ├── movies.go ├── movies_search.go ├── movirs_struct.go ├── search_tv.go ├── search_tv_test.go ├── tmdb.go ├── tmdb_struct.go ├── tmdb_test.go ├── tv.go ├── tv_aggregate_credits.go ├── tv_content_ratings.go ├── tv_episode.go ├── tv_episode_group.go ├── tv_nfo.go └── tv_test.go └── utils ├── http.go ├── logger.go ├── nfo.go ├── string.go ├── string_test.go ├── time.go ├── video.go └── video_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = false 9 | max_line_length = 120 10 | tab_width = 4 11 | 12 | [*.go] 13 | indent_style = tab 14 | 15 | [*.json] 16 | indent_size = 4 17 | 18 | [Makefile] 19 | indent_style = tab 20 | 21 | [*.yml] 22 | tab_width = 2 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version-file: 'go.mod' 19 | 20 | - name: Build 21 | run: make release 22 | 23 | - name: Release 24 | uses: softprops/action-gh-release@v1 25 | with: 26 | files: release/* 27 | draft: true 28 | generate_release_notes: true 29 | body: | 30 | * 这是 GitHub Actions 自动化部署,更新日志应该很快会手动更新 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | *.test 9 | *.out 10 | vendor 11 | config.json 12 | release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 盈利必须捐赠协议 2 | PMD(Profitable Must Donate) License 3 | 4 | Copyright (c) <2021> 5 | 6 | 此授予任何人免费获得本软件和相关文档文件(“软件”)副本的许可,不受限制地处理本软件,包括但不限于使用、复制、修改、合并 、发布、分发、再许可的权利, 被许可人有权利使用、复制、修改、合并、出版发行、散布、再许可和/或贩售软件及软件的副本,及授予被供应人同等权利,必须服从以下义务: 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | 1. 任意个人或组织,使用本软件和相关文档文件获取大于1美元的收入,必须捐赠本项目,金额不限。 10 | Any individual or organization that uses this software and related documents to obtain income greater than US$1 must donate to this project, and the amount is unlimited. 11 | 12 | 2. 在软件和软件的所有副本中都必须包含以上著作权声明和本许可声明。 13 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 14 | 15 | 我不在乎你喜不喜欢我的项目。 16 | I DON'T CARE IF YOU LIKE MY PROJECT. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | NAME=kodi-tmdb 3 | VERSION=$(shell git describe --tags || echo "dev-master") 4 | RELEASE_DIR=release 5 | GOBUILD=CGO_ENABLED=0 go build -trimpath -ldflags '-w -s -X "main.buildVersion=$(VERSION)"' 6 | 7 | PLATFORM_LIST = \ 8 | darwin-amd64 \ 9 | darwin-arm64 \ 10 | linux-amd64 \ 11 | linux-arm64 \ 12 | linux-arm 13 | 14 | WINDOWS_ARCH_LIST = windows-amd64 15 | 16 | all: linux-amd64 linux-arm64 linux-arm darwin-amd64 darwin-arm64 windows-amd64 17 | 18 | darwin-amd64: 19 | GOARCH=amd64 GOOS=darwin $(GOBUILD) -o $(RELEASE_DIR)/$(NAME)-$@ 20 | cp example.config.json $(RELEASE_DIR)/ 21 | 22 | darwin-arm64: 23 | GOARCH=arm64 GOOS=darwin $(GOBUILD) -o $(RELEASE_DIR)/$(NAME)-$@ 24 | cp example.config.json $(RELEASE_DIR)/ 25 | 26 | linux-amd64: 27 | GOARCH=amd64 GOOS=linux $(GOBUILD) -o $(RELEASE_DIR)/$(NAME)-$@ 28 | cp example.config.json $(RELEASE_DIR)/ 29 | 30 | linux-arm64: 31 | GOARCH=arm64 GOOS=linux $(GOBUILD) -o $(RELEASE_DIR)/$(NAME)-$@ 32 | cp example.config.json $(RELEASE_DIR)/ 33 | 34 | linux-arm: 35 | GOARCH=arm GOOS=linux $(GOBUILD) -o $(RELEASE_DIR)/$(NAME)-$@ 36 | cp example.config.json $(RELEASE_DIR)/ 37 | 38 | windows-amd64: 39 | GOARCH=amd64 GOOS=windows $(GOBUILD) -o $(RELEASE_DIR)/$(NAME)-$@.exe 40 | cp example.config.json $(RELEASE_DIR)/ 41 | 42 | gz_releases=$(addsuffix .gz, $(PLATFORM_LIST)) 43 | zip_releases=$(addsuffix .zip, $(WINDOWS_ARCH_LIST)) 44 | 45 | $(gz_releases): %.gz : % 46 | chmod +x $(RELEASE_DIR)/$(NAME)-$(basename $@) 47 | zip -m -j $(RELEASE_DIR)/$(NAME)-$(basename $@)-$(VERSION).zip $(RELEASE_DIR)/$(NAME)-$(basename $@) $(RELEASE_DIR)/example.config.json 48 | 49 | $(zip_releases): %.zip : % 50 | zip -m -j $(RELEASE_DIR)/$(NAME)-$(basename $@)-$(VERSION).zip $(RELEASE_DIR)/$(NAME)-$(basename $@).exe $(RELEASE_DIR)/example.config.json 51 | 52 | release: $(gz_releases) $(zip_releases) 53 | 54 | clean: 55 | rm $(RELEASE_DIR)/* 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kodi-metadata-tmdb-cli 2 | 3 | 电影、电视剧刮削器命令行版本,使用TMDB数据源生成Kodi兼容的NFO文件和相关图片,可用来代替Kodi自带以及tinyMediaManager等其他第三方的刮削器。 4 | 5 | 有定时扫描扫描、实时监听新增文件两种模式,可配置有新增时触发Kodi更新媒体库。 6 | 7 | # 怎么使用 8 | 9 | 1. 打开 Kodi 设置 - 媒体 - 视频 - 更改内容(仅限电影和剧集类型) - 信息提供者改为:Local information only 10 | 2. 根据平台[下载](https://github.com/fengqi/kodi-metadata-tmdb-cli/releases)对应的文件,配置 `config.json`并后台运行。 11 | 12 | > 本程序必须和下载软件(如Transmission、µTorrent等)运行在同一个环境,不然实时监听模式不生效。 13 | > 详细配置参考 [配置总览](https://github.com/fengqi/kodi-metadata-tmdb-cli/wiki/%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6) 14 | 15 | # 功能列表 16 | 17 | - [x] 从TMDB获取电视剧、电视剧分集、电视剧合集、电视剧剧集组、电影、电影合集信息 18 | - [x] 从TMDB获取演员列表、封面图片、海报图片、内容分级、logo 19 | - [x] 定时扫描电影、电视剧、音乐视频文件和目录 20 | - [x] 实时监听新添加的电影、电视剧、音乐视频文件和目录 21 | - [x] 命名不规范或有歧义的电影、电视剧支持手动指定id 22 | - [x] 命名不规范的电视剧支持指定season 23 | - [x] 多个电视剧剧集组支持指定分组id 24 | - [ ] 多个搜索结果尝试根据特征信息确定 25 | - [x] 更新NFO文件后触发Kodi更新数据 26 | - [x] 支持 .part 和 .!qb 文件 27 | - [x] 音乐视频文件使用ffmpeg提取缩略图和视频音频信息 28 | 29 | # 参考 30 | 31 | > 本程序部分逻辑借鉴了tinyMediaManager(TMM)的思路,但并非是抄袭,因为编程语言不同,整体思路也不同。 32 | 33 | - Kodi v19 (Matrix) JSON-RPC API/V12 https://kodi.wiki/view/JSON-RPC_API/v12 34 | - Kodi v19 (Matrix) NFO files https://kodi.wiki/view/NFO_files 35 | - Kodi Artwork types https://kodi.wiki/view/Artwork_types 36 | - TMDB Api Overview https://www.themoviedb.org/documentation/api 37 | - TMDB Api V3 https://developers.themoviedb.org/3/getting-started/introduction 38 | - File system notifications for Go https://github.com/fsnotify/fsnotify 39 | - tinyMediaManager https://gitlab.com/tinyMediaManager/tinyMediaManager 40 | 41 | # 感谢 42 | ![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg) 43 | -------------------------------------------------------------------------------- /collector/collector.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/common/watcher" 5 | "fengqi/kodi-metadata-tmdb-cli/config" 6 | "fengqi/kodi-metadata-tmdb-cli/media_file" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type collector struct { 12 | channel chan *media_file.MediaFile 13 | watcher *watcher.Watcher 14 | wg *sync.WaitGroup 15 | } 16 | 17 | var ins *collector 18 | 19 | // Run 运行扫描 20 | func Run() { 21 | ins = &collector{ 22 | channel: make(chan *media_file.MediaFile, 100), 23 | watcher: watcher.InitWatcher("collector"), 24 | wg: &sync.WaitGroup{}, 25 | } 26 | 27 | go ins.watcher.Run(ins.watcherCallback) 28 | go ins.runScan() 29 | go ins.runProcess() 30 | 31 | ticker := time.NewTicker(time.Second * time.Duration(config.Collector.CronSeconds)) 32 | for { 33 | select { 34 | case <-ticker.C: 35 | ins.runScan() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /collector/process.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/media_file" 5 | "fengqi/kodi-metadata-tmdb-cli/movies" 6 | "fengqi/kodi-metadata-tmdb-cli/shows" 7 | "fengqi/kodi-metadata-tmdb-cli/utils" 8 | "log" 9 | ) 10 | 11 | // runProcess 处理扫描到的文件 12 | func (c *collector) runProcess() { 13 | utils.Logger.Debug("run process") 14 | 15 | for { 16 | select { 17 | case file := <-c.channel: 18 | utils.Logger.DebugF("receive task: %s", file.Filename) 19 | 20 | switch file.VideoType { 21 | case media_file.Movies: 22 | if err := movies.Process(file); err != nil { 23 | log.Printf("prcess movies error: %s\n", err) 24 | } 25 | case media_file.TvShows: 26 | if err := shows.Process(file); err != nil { 27 | log.Printf("pricess shows error: %s\n", err) 28 | } 29 | case media_file.MusicVideo: 30 | // todo 31 | } 32 | 33 | c.wg.Done() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /collector/scan.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/kodi" 6 | "fengqi/kodi-metadata-tmdb-cli/media_file" 7 | "fengqi/kodi-metadata-tmdb-cli/utils" 8 | "github.com/fengqi/lrace" 9 | "io/fs" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "time" 14 | ) 15 | 16 | // collector 运行扫描 17 | func (c *collector) runScan() { 18 | go c.scanDir(config.Collector.MoviesDir, media_file.Movies) 19 | go c.scanDir(config.Collector.ShowsDir, media_file.TvShows) 20 | go c.scanDir(config.Collector.MusicVideosDir, media_file.MusicVideo) 21 | 22 | time.Sleep(time.Second * 3) 23 | c.wg.Wait() 24 | 25 | // 扫描完成,通知kodi刷新媒体库 26 | if config.Collector.CronScanKodi { 27 | log.Println("scan done, refresh kodi library") 28 | kodi.Rpc.VideoLibrary.Scan("", false) 29 | } 30 | 31 | // 扫描完成后,通知kodi清理媒体库 32 | if config.Kodi.CleanLibrary { 33 | log.Println("scan done, clean kodi library") 34 | kodi.Rpc.AddCleanTask("") 35 | } 36 | } 37 | 38 | // scanDir 扫描目录 39 | func (c *collector) scanDir(roots []string, videoType media_file.VideoType) { 40 | for _, root := range roots { 41 | if f, err := os.Stat(root); err != nil || !f.IsDir() { 42 | utils.Logger.WarningF("%s is not a directory", root) 43 | continue 44 | } 45 | 46 | err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if d.Name()[0:1] == "." { 52 | return nil 53 | } 54 | 55 | if d.IsDir() { 56 | if c.skipFolders(path, d.Name()) { 57 | utils.Logger.DebugF("skip folder by config: %s", d.Name()) 58 | return fs.SkipDir 59 | } 60 | 61 | c.watcher.Add(path) 62 | } 63 | 64 | mf := media_file.NewMediaFile(path, d.Name(), videoType) 65 | if mf.IsBluRay() { 66 | c.wg.Add(1) 67 | c.channel <- mf 68 | return fs.SkipDir 69 | } 70 | if mf.IsVideo() { 71 | c.wg.Add(1) 72 | c.channel <- mf 73 | } 74 | 75 | return nil 76 | }) 77 | 78 | if err != nil { 79 | utils.Logger.WarningF("scan dir %s error: %s", root, err) 80 | } 81 | } 82 | } 83 | 84 | // skipFolders 检查是否跳过目录 85 | func (c *collector) skipFolders(path, filename string) bool { 86 | base := filepath.Base(path) 87 | return lrace.InArray(config.Collector.SkipFolders, base) || 88 | lrace.InArray(config.Collector.SkipFolders, filename) 89 | } 90 | -------------------------------------------------------------------------------- /collector/watcher.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/media_file" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | // watcherCallback 监听文件变化的回调函数 12 | // todo 部分逻辑和scanDir重复,考虑复用 13 | func (c *collector) watcherCallback(filename string, fi os.FileInfo) { 14 | if fi.Name()[0:1] == "." { 15 | return 16 | } 17 | 18 | if fi.IsDir() { 19 | if c.skipFolders(filename, fi.Name()) { 20 | utils.Logger.DebugF("skip folder by config: %s", fi.Name()) 21 | return 22 | } 23 | 24 | c.watcher.Add(filename) 25 | } 26 | 27 | var videoType media_file.VideoType 28 | for _, item := range config.Collector.MoviesDir { 29 | if strings.HasPrefix(filename, item) { 30 | videoType = media_file.Movies 31 | break 32 | } 33 | } 34 | for _, item := range config.Collector.ShowsDir { 35 | if strings.HasPrefix(filename, item) { 36 | videoType = media_file.TvShows 37 | break 38 | } 39 | } 40 | for _, item := range config.Collector.MusicVideosDir { 41 | if strings.HasPrefix(filename, item) { 42 | videoType = media_file.MusicVideo 43 | break 44 | } 45 | } 46 | 47 | mf := media_file.NewMediaFile(filename, fi.Name(), videoType) 48 | if mf.IsBluRay() { 49 | c.wg.Add(1) 50 | c.channel <- mf 51 | } 52 | if mf.IsVideo() { 53 | c.wg.Add(1) 54 | c.channel <- mf 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /common/constants/cache.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | TmdbCacheDir = "tmdb" 5 | ) 6 | -------------------------------------------------------------------------------- /common/memcache/gocache.go: -------------------------------------------------------------------------------- 1 | package memcache 2 | 3 | import ( 4 | "github.com/patrickmn/go-cache" 5 | "time" 6 | ) 7 | 8 | var Cache *cache.Cache 9 | 10 | func InitCache() { 11 | Cache = cache.New(time.Minute*5, time.Minute*20) 12 | } 13 | -------------------------------------------------------------------------------- /common/watcher/watcher.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "github.com/fsnotify/fsnotify" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | type callback func(filename string, fileInfo os.FileInfo) 12 | 13 | type Watcher struct { 14 | name string 15 | watcher *fsnotify.Watcher 16 | callback callback 17 | } 18 | 19 | func InitWatcher(taskName string) *Watcher { 20 | watcher, err := fsnotify.NewWatcher() 21 | if err != nil { 22 | utils.Logger.FatalF("new %s watcher err: %v", taskName, err) 23 | } 24 | 25 | w := &Watcher{ 26 | name: taskName, 27 | watcher: watcher, 28 | } 29 | 30 | return w 31 | } 32 | 33 | func (w *Watcher) Run(callback callback) { 34 | w.callback = callback 35 | go w.runWatcher() 36 | } 37 | 38 | func (w *Watcher) runWatcher() { 39 | if !config.Collector.Watcher || w.callback == nil { 40 | return 41 | } 42 | 43 | utils.Logger.DebugF("run %s watcher", w.name) 44 | 45 | for { 46 | select { 47 | case event, ok := <-w.watcher.Events: 48 | if !ok { 49 | continue 50 | } 51 | 52 | if !event.Has(fsnotify.Create) || w.skipFolders(filepath.Dir(event.Name), event.Name) { 53 | continue 54 | } 55 | 56 | utils.Logger.InfoF("created file: %s", event.Name) 57 | 58 | fileInfo, err := os.Stat(event.Name) 59 | if fileInfo == nil || err != nil { 60 | utils.Logger.WarningF("get file: %s stat err: %v", event.Name, err) 61 | continue 62 | } 63 | 64 | w.callback(event.Name, fileInfo) 65 | 66 | case err, ok := <-w.watcher.Errors: 67 | if !ok { 68 | return 69 | } 70 | 71 | utils.Logger.ErrorF("%s watcher error: %v", w.name, err) 72 | } 73 | } 74 | } 75 | 76 | func (w *Watcher) Add(name string) { 77 | if !config.Collector.Watcher { 78 | return 79 | } 80 | 81 | utils.Logger.DebugF("add dir: %s to %s watcher", name, w.name) 82 | 83 | err := w.watcher.Add(name) 84 | if err != nil { 85 | utils.Logger.FatalF("add dir: %s to %s watcher err: %v", name, w.name, err) 86 | } 87 | } 88 | 89 | // todo 代码复用 90 | func (w *Watcher) skipFolders(path, filename string) bool { 91 | base := filepath.Base(path) 92 | for _, item := range config.Collector.SkipFolders { 93 | if filename[0:1] == "." || item == base || item == filename { 94 | return true 95 | } 96 | } 97 | return false 98 | } 99 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | ) 8 | 9 | var ( 10 | Log *LogConfig 11 | Ffmpeg *FfmpegConfig 12 | Tmdb *TmdbConfig 13 | Kodi *KodiConfig 14 | Collector *CollectorConfig 15 | ) 16 | 17 | func LoadConfig(file string) { 18 | bytes, err := os.ReadFile(file) 19 | if err != nil { 20 | log.Fatalf("load config err: %v", err) 21 | } 22 | 23 | c := &Config{} 24 | err = json.Unmarshal(bytes, c) 25 | if err != nil { 26 | log.Fatalf("parse config err: %v", err) 27 | } 28 | 29 | Log = c.Log 30 | Ffmpeg = c.Ffmpeg 31 | Tmdb = c.Tmdb 32 | Kodi = c.Kodi 33 | Collector = c.Collector 34 | } 35 | -------------------------------------------------------------------------------- /config/struct.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Log *LogConfig `json:"log"` // 日志配置 5 | Ffmpeg *FfmpegConfig `json:"ffmpeg"` // ffmpeg配置,给音乐视频使用的 6 | Tmdb *TmdbConfig `json:"tmdb"` // TMDB 配置 7 | Kodi *KodiConfig `json:"kodi"` // kodi配置 8 | Collector *CollectorConfig `json:"collector"` // 刮削配置 9 | } 10 | 11 | type KodiConfig struct { 12 | Enable bool `json:"enable"` // 是否开启 kodi 通知 13 | CleanLibrary bool `json:"clean_library"` // 是否清理媒体库 14 | JsonRpc string `json:"json_rpc"` // kodi rpc 路径 15 | Timeout int `json:"timeout"` // 连接kodi超时时间 16 | Username string `json:"username"` // rpc 用户名 17 | Password string `json:"password"` // rpc 密码 18 | } 19 | 20 | type LogConfig struct { 21 | Mode int `json:"mode"` // 日志模式:1标准输出,2日志文件,3标准输出和日志文件 22 | Level int `json:"level"` // 日志等级,0-4分别是:debug,info,warning,error,fatal 23 | File string `json:"file"` // 日志文件路径 24 | } 25 | 26 | type FfmpegConfig struct { 27 | MaxWorker int `json:"max_worker"` // 最大进程数:建议为逻辑CPU个数 28 | FfmpegPath string `json:"ffmpeg_path"` // ffmpeg 可执行文件路径 29 | FfprobePath string `json:"ffprobe_path"` // ffprobe 可执行文件路径 30 | } 31 | 32 | type TmdbConfig struct { 33 | ApiHost string `json:"api_host"` // TMDB 接口地址 34 | ApiKey string `json:"api_key"` // api key 35 | ImageHost string `json:"image_host"` // 图片地址 36 | Language string `json:"language"` // 语言 37 | Rating string `json:"rating"` // 内容分级 38 | Proxy string `json:"proxy"` // 请求TMDB经过代理,支持 http、https、socks5、socks5h 39 | } 40 | 41 | type CollectorConfig struct { 42 | Watcher bool `json:"watcher"` // 是否开启文件监听,比定时扫描及时 43 | CronSeconds int `json:"cron_seconds"` // 定时扫描频率 44 | CronScanKodi bool `json:"cron_scan_kodi"` // 定时扫描后触发kodi扫描 45 | FilterTmpSuffix bool `json:"filter_tmp_suffix"` // 过滤临时文件后缀:.!ut、.!qB 46 | TmpSuffix []string `json:"tmp_suffix"` // 临时文件后缀列表 47 | SkipFolders []string `json:"skip_folders"` // 跳过的目录,可多个 48 | MoviesNfoMode int `json:"movies_nfo_mode"` // 电影NFO写入模式:1 movie.nfo,2 .nfo 49 | MoviesDir []string `json:"movies_dir"` // 电影文件根目录,可多个 50 | ShowsDir []string `json:"shows_dir"` // 电视剧文件根目录,可多个 51 | MusicVideosDir []string `json:"music_videos_dir"` // 音乐视频文件根目录,可多个 52 | } 53 | -------------------------------------------------------------------------------- /example.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "mode": 1, 4 | "level": 2, 5 | "file": "/tmp/tmdb-collector.log" 6 | }, 7 | "tmdb": { 8 | "api_host": "https://api.themoviedb.org", 9 | "image_host": "https://image.tmdb.org", 10 | "api_key": "a52fb1bab999ef3918b3e2864d584cb6", 11 | "language": "zh-CN", 12 | "proxy": "(socks5h|socks5|http|https)://user@password@127.0.0.1:1080", 13 | "rating": "US" 14 | }, 15 | "collector": { 16 | "watcher": true, 17 | "cron_seconds": 3600, 18 | "cron_scan_kodi": false, 19 | "skip_folders": [ 20 | "tmdb", 21 | "@eaDir", 22 | "CERTIFICATE", 23 | "$RECYCLE.BIN", 24 | "RECYCLER", 25 | "SYSTEM VOLUME INFORMATION", 26 | "@EADIR", 27 | "ADV_OBJ", 28 | "PLEX VERSIONS", 29 | "Sample", 30 | "gallery", 31 | "metadata" 32 | ], 33 | "filter_tmp_suffix": true, 34 | "tmp_suffix":[ 35 | ".part", 36 | ".!qB", 37 | ".!qb", 38 | ".!ut" 39 | ], 40 | "movies_nfo_mode": 2, 41 | "movies_dir": [ 42 | "/volume1/down/movies" 43 | ], 44 | "shows_dir": [ 45 | "/volume1/down/shows" 46 | ], 47 | "music_videos_dir": [ 48 | "/volume1/down/videos" 49 | ] 50 | }, 51 | "kodi": { 52 | "enable": true, 53 | "clean_library": false, 54 | "json_rpc": "http://192.168.1.123:8080/jsonrpc", 55 | "timeout": 1, 56 | "username": "kodi", 57 | "password": "123456" 58 | }, 59 | "ffmpeg": { 60 | "max_worker": 4, 61 | "ffmpeg_path": "/usr/local/ffmpeg-5.0.1-amd64-static/ffmpeg", 62 | "ffprobe_path": "/usr/local/ffmpeg-5.0.1-amd64-static/ffprobe" 63 | } 64 | } -------------------------------------------------------------------------------- /ffmpeg/ffmpeg.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "github.com/fengqi/lrace" 6 | ) 7 | 8 | func InitFfmpeg() { 9 | SetFfmpeg() 10 | SetFfprobe() 11 | } 12 | 13 | func SetFfmpeg() { 14 | if !lrace.FileExist(config.Ffmpeg.FfmpegPath) { 15 | return 16 | } 17 | 18 | ffmpeg = config.Ffmpeg.FfmpegPath 19 | } 20 | 21 | func SetFfprobe() { 22 | if !lrace.FileExist(config.Ffmpeg.FfprobePath) { 23 | return 24 | } 25 | 26 | ffprobe = config.Ffmpeg.FfprobePath 27 | } 28 | -------------------------------------------------------------------------------- /ffmpeg/ffmpeg_struct.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | var ffmpeg = "ffmpeg" 4 | var ffprobe = "ffprobe" 5 | -------------------------------------------------------------------------------- /ffmpeg/frame.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | "time" 10 | ) 11 | 12 | func Frame(fileName, outfile string, options ...string) error { 13 | return FrameWithTimeout(fileName, outfile, 0, options...) 14 | } 15 | 16 | func FrameWithTimeout(fileName, outfile string, timeout time.Duration, options ...string) error { 17 | options = append([]string{ 18 | "-vframes", "1", 19 | "-format", "image2", 20 | "-vcodec", "mjpeg", 21 | }, options...) 22 | 23 | return FrameWithTimeoutExec(fileName, outfile, timeout, options...) 24 | } 25 | 26 | func FrameWithTimeoutExec(filename, outfile string, timeout time.Duration, options ...string) error { 27 | args := append([]string{ 28 | "-i", filename, 29 | }, options...) 30 | args = append(args, "-y") 31 | args = append(args, outfile) 32 | 33 | ctx := context.Background() 34 | if timeout > 0 { 35 | var cancel func() 36 | ctx, cancel = context.WithTimeout(context.Background(), timeout) 37 | defer cancel() 38 | } 39 | 40 | var outputBuf bytes.Buffer 41 | var stdErr bytes.Buffer 42 | 43 | cmd := exec.CommandContext(ctx, ffmpeg, args...) 44 | cmd.Stdout = &outputBuf 45 | cmd.Stderr = &stdErr 46 | 47 | err := cmd.Run() 48 | if err != nil { 49 | return errors.New(fmt.Sprintf("%s\n %s", err.Error(), stdErr.String())) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /ffmpeg/probe.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os/exec" 10 | "time" 11 | ) 12 | 13 | func Probe(filename string, options ...string) (*ProbeData, error) { 14 | return ProbeWithTimeout(filename, 0, options...) 15 | } 16 | 17 | func ProbeWithTimeout(filename string, timeout time.Duration, options ...string) (*ProbeData, error) { 18 | options = append([]string{ 19 | "-loglevel", "fatal", 20 | "-print_format", "json", 21 | "-show_format", 22 | "-show_streams", 23 | }, options...) 24 | 25 | return ProbeWithTimeoutExec(filename, timeout, options...) 26 | } 27 | 28 | func ProbeWithTimeoutExec(filename string, timeout time.Duration, options ...string) (*ProbeData, error) { 29 | options = append(options, filename) 30 | 31 | ctx := context.Background() 32 | if timeout > 0 { 33 | var cancel func() 34 | ctx, cancel = context.WithTimeout(context.Background(), timeout) 35 | defer cancel() 36 | } 37 | 38 | var outputBuf bytes.Buffer 39 | var stdErr bytes.Buffer 40 | 41 | cmd := exec.CommandContext(ctx, ffprobe, options...) 42 | cmd.Stdout = &outputBuf 43 | cmd.Stderr = &stdErr 44 | 45 | err := cmd.Run() 46 | if err != nil { 47 | return nil, errors.New(fmt.Sprintf("%s\n %s", err.Error(), stdErr.String())) 48 | } 49 | 50 | data := &ProbeData{} 51 | err = json.Unmarshal(outputBuf.Bytes(), data) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return data, nil 57 | } 58 | -------------------------------------------------------------------------------- /ffmpeg/probe_struct.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | // https://github.com/vansante/go-ffprobe/blob/v2/probedata.go 4 | 5 | import ( 6 | "time" 7 | ) 8 | 9 | // StreamType represents a media stream type like video, audio, subtitles, etc 10 | type StreamType string 11 | 12 | const ( 13 | // StreamAny means any type of stream 14 | StreamAny StreamType = "" 15 | // StreamVideo is a video stream 16 | StreamVideo StreamType = "video" 17 | // StreamAudio is an audio stream 18 | StreamAudio StreamType = "audio" 19 | // StreamSubtitle is a subtitle stream 20 | StreamSubtitle StreamType = "subtitle" 21 | // StreamData is a data stream 22 | StreamData StreamType = "data" 23 | // StreamAttachment is an attachment stream 24 | StreamAttachment StreamType = "attachment" 25 | ) 26 | 27 | // ProbeData is the root json data structure returned by an ffprobe. 28 | type ProbeData struct { 29 | Streams []*Stream `json:"streams"` 30 | Format *Format `json:"format"` 31 | } 32 | 33 | // Format is a json data structure to represent formats 34 | type Format struct { 35 | Filename string `json:"filename"` 36 | NBStreams int `json:"nb_streams"` 37 | NBPrograms int `json:"nb_programs"` 38 | FormatName string `json:"format_name"` 39 | FormatLongName string `json:"format_long_name"` 40 | StartTimeSeconds float64 `json:"start_time,string"` 41 | DurationSeconds float64 `json:"duration,string"` 42 | Size string `json:"size"` 43 | BitRate string `json:"bit_rate"` 44 | ProbeScore int `json:"probe_score"` 45 | Tags *FormatTags `json:"tags"` 46 | } 47 | 48 | // FormatTags is a json data structure to represent format tags 49 | type FormatTags struct { 50 | MajorBrand string `json:"major_brand"` 51 | MinorVersion string `json:"minor_version"` 52 | CompatibleBrands string `json:"compatible_brands"` 53 | CreationTime string `json:"creation_time"` 54 | } 55 | 56 | // Stream is a json data structure to represent streams. 57 | // A stream can be a video, audio, subtitle, etc type of stream. 58 | type Stream struct { 59 | Index int `json:"index"` 60 | ID string `json:"id"` 61 | CodecName string `json:"codec_name"` 62 | CodecLongName string `json:"codec_long_name"` 63 | CodecType string `json:"codec_type"` 64 | CodecTimeBase string `json:"codec_time_base"` 65 | CodecTagString string `json:"codec_tag_string"` 66 | CodecTag string `json:"codec_tag"` 67 | RFrameRate string `json:"r_frame_rate"` 68 | AvgFrameRate string `json:"avg_frame_rate"` 69 | TimeBase string `json:"time_base"` 70 | StartPts int `json:"start_pts"` 71 | StartTime string `json:"start_time"` 72 | DurationTs uint64 `json:"duration_ts"` 73 | Duration string `json:"duration"` 74 | BitRate string `json:"bit_rate"` 75 | BitsPerRawSample string `json:"bits_per_raw_sample"` 76 | NbFrames string `json:"nb_frames"` 77 | Disposition StreamDisposition `json:"disposition,omitempty"` 78 | Tags StreamTags `json:"tags,omitempty"` 79 | Profile string `json:"profile,omitempty"` 80 | Width int `json:"width"` 81 | Height int `json:"height"` 82 | HasBFrames int `json:"has_b_frames,omitempty"` 83 | SampleAspectRatio string `json:"sample_aspect_ratio,omitempty"` 84 | DisplayAspectRatio string `json:"display_aspect_ratio,omitempty"` 85 | PixFmt string `json:"pix_fmt,omitempty"` 86 | Level int `json:"level,omitempty"` 87 | ColorRange string `json:"color_range,omitempty"` 88 | ColorSpace string `json:"color_space,omitempty"` 89 | SampleFmt string `json:"sample_fmt,omitempty"` 90 | SampleRate string `json:"sample_rate,omitempty"` 91 | Channels int `json:"channels,omitempty"` 92 | ChannelLayout string `json:"channel_layout,omitempty"` 93 | BitsPerSample int `json:"bits_per_sample,omitempty"` 94 | } 95 | 96 | // StreamDisposition is a json data structure to represent stream dispositions 97 | type StreamDisposition struct { 98 | Default int `json:"default"` 99 | Dub int `json:"dub"` 100 | Original int `json:"original"` 101 | Comment int `json:"comment"` 102 | Lyrics int `json:"lyrics"` 103 | Karaoke int `json:"karaoke"` 104 | Forced int `json:"forced"` 105 | HearingImpaired int `json:"hearing_impaired"` 106 | VisualImpaired int `json:"visual_impaired"` 107 | CleanEffects int `json:"clean_effects"` 108 | AttachedPic int `json:"attached_pic"` 109 | } 110 | 111 | // StreamTags is a json data structure to represent stream tags 112 | type StreamTags struct { 113 | Rotate int `json:"rotate,string,omitempty"` 114 | CreationTime string `json:"creation_time,omitempty"` 115 | Language string `json:"language,omitempty"` 116 | Title string `json:"title,omitempty"` 117 | Encoder string `json:"encoder,omitempty"` 118 | Location string `json:"location,omitempty"` 119 | } 120 | 121 | // StartTime returns the start time of the media file as a time.Duration 122 | func (f *Format) StartTime() (duration time.Duration) { 123 | return time.Duration(f.StartTimeSeconds * float64(time.Second)) 124 | } 125 | 126 | // Duration returns the duration of the media file as a time.Duration 127 | func (f *Format) Duration() (duration time.Duration) { 128 | return time.Duration(f.DurationSeconds * float64(time.Second)) 129 | } 130 | 131 | // StreamType returns all streams which are of the given type 132 | func (p *ProbeData) StreamType(streamType StreamType) (streams []Stream) { 133 | for _, s := range p.Streams { 134 | if s == nil { 135 | continue 136 | } 137 | switch streamType { 138 | case StreamAny: 139 | streams = append(streams, *s) 140 | default: 141 | if s.CodecType == string(streamType) { 142 | streams = append(streams, *s) 143 | } 144 | } 145 | } 146 | return streams 147 | } 148 | 149 | // FirstVideoStream returns the first video stream found 150 | func (p *ProbeData) FirstVideoStream() *Stream { 151 | return p.firstStream(StreamVideo) 152 | } 153 | 154 | // FirstAudioStream returns the first audio stream found 155 | func (p *ProbeData) FirstAudioStream() *Stream { 156 | return p.firstStream(StreamAudio) 157 | } 158 | 159 | // FirstSubtitleStream returns the first subtitle stream found 160 | func (p *ProbeData) FirstSubtitleStream() *Stream { 161 | return p.firstStream(StreamSubtitle) 162 | } 163 | 164 | // FirstDataStream returns the first data stream found 165 | func (p *ProbeData) FirstDataStream() *Stream { 166 | return p.firstStream(StreamData) 167 | } 168 | 169 | // FirstAttachmentStream returns the first attachment stream found 170 | func (p *ProbeData) FirstAttachmentStream() *Stream { 171 | return p.firstStream(StreamAttachment) 172 | } 173 | 174 | func (p *ProbeData) firstStream(streamType StreamType) *Stream { 175 | for _, s := range p.Streams { 176 | if s == nil { 177 | continue 178 | } 179 | if s.CodecType == string(streamType) { 180 | return s 181 | } 182 | } 183 | return nil 184 | } 185 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module fengqi/kodi-metadata-tmdb-cli 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/agiledragon/gomonkey/v2 v2.13.0 7 | github.com/fengqi/lrace v0.0.0-20250601104817-3cb5590f1dec 8 | github.com/fsnotify/fsnotify v1.9.0 9 | github.com/patrickmn/go-cache v2.1.0+incompatible 10 | github.com/stretchr/testify v1.10.0 11 | golang.org/x/net v0.40.0 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | golang.org/x/sys v0.33.0 // indirect 18 | gopkg.in/yaml.v3 v3.0.1 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo= 2 | github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fengqi/lrace v0.0.0-20250601104817-3cb5590f1dec h1:R4VDNTV21kWD1uC55gtLU9B8fwnB5WxprWSOP+SxOPk= 6 | github.com/fengqi/lrace v0.0.0-20250601104817-3cb5590f1dec/go.mod h1:xGFFN3/sk5drSZ30RtNzrerZYx6LCSIIsKQeh72SoqM= 7 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 8 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 9 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 10 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 11 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 12 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= 16 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 17 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 18 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 19 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 20 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 21 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 22 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 23 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 24 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 25 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 26 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 27 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /kodi/XBMC.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type XBMC struct{} 8 | 9 | // GetInfoBooleans Retrieve info booleans about Kodi and the system 10 | // https://kodi.wiki/view/List_of_boolean_conditions 11 | func (x *XBMC) GetInfoBooleans(booleans []string) map[string]bool { 12 | bytes, err := Rpc.request(&JsonRpcRequest{ 13 | Method: "XBMC.GetInfoBooleans", 14 | Params: &GetInfoBooleansRequest{ 15 | Booleans: booleans, 16 | }, 17 | }) 18 | 19 | if err != nil { 20 | return nil 21 | } 22 | 23 | resp := &JsonRpcResponse{} 24 | err = json.Unmarshal(bytes, resp) 25 | if err != nil { 26 | return nil 27 | } 28 | 29 | if resp != nil && resp.Result != nil { 30 | jsonBytes, _ := json.Marshal(resp.Result) 31 | info := make(map[string]bool, 0) 32 | _ = json.Unmarshal(jsonBytes, &info) 33 | return info 34 | } 35 | return nil 36 | } 37 | 38 | // GetInfoLabels 获取Kodi和系统相关信息 39 | // https://kodi.wiki/view/JSON-RPC_API/v13#XBMC.GetInfoLabels 40 | // https://kodi.wiki/view/InfoLabels 41 | func (x *XBMC) GetInfoLabels(labels []string) map[string]interface{} { 42 | // TODO 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /kodi/XBMC_struct.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | type GetInfoBooleansRequest struct { 4 | Booleans []string `json:"booleans"` 5 | } 6 | -------------------------------------------------------------------------------- /kodi/files.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | ) 7 | 8 | type Files struct{} 9 | 10 | // GetSources 获取kodi添加的媒体源 11 | func (f *Files) GetSources(media string) []*FileSource { 12 | body, _ := Rpc.request(&JsonRpcRequest{ 13 | Method: "Files.GetSources", 14 | Params: GetSourcesRequest{ 15 | Media: media, 16 | }, 17 | }) 18 | 19 | resp := &JsonRpcResponse{} 20 | err := json.Unmarshal(body, resp) 21 | if err != nil { 22 | utils.Logger.WarningF("parse Files.GetSources response err: %v", err) 23 | return nil 24 | } 25 | 26 | if resp != nil && resp.Result != nil { 27 | jsonBytes, _ := json.Marshal(resp.Result) 28 | sourcesResp := &GetSourcesResponse{} 29 | err = json.Unmarshal(jsonBytes, sourcesResp) 30 | if err == nil { 31 | return sourcesResp.Sources 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /kodi/files_struct.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | // GetSourcesRequest Files.GetSources Parameters 4 | type GetSourcesRequest struct { 5 | Media string `json:"media"` 6 | } 7 | 8 | // GetSourcesResponse Files.GetSources Returns 9 | type GetSourcesResponse struct { 10 | Limits LimitsResult `json:"limits"` 11 | Sources []*FileSource `json:"sources"` 12 | } 13 | 14 | type FileSource struct { 15 | File string `json:"file"` 16 | Label string `json:"label"` 17 | } 18 | -------------------------------------------------------------------------------- /kodi/kodi.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fengqi/kodi-metadata-tmdb-cli/config" 8 | "fengqi/kodi-metadata-tmdb-cli/utils" 9 | "io" 10 | "net/http" 11 | "strconv" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | var Rpc *JsonRpc 17 | var httpClient *http.Client 18 | 19 | func InitKodi() { 20 | Rpc = &JsonRpc{ 21 | refreshQueue: make(map[string]struct{}, 0), 22 | scanQueue: make(map[string]struct{}, 0), 23 | refreshLock: &sync.RWMutex{}, 24 | scanLock: &sync.RWMutex{}, 25 | VideoLibrary: &VideoLibrary{ 26 | scanLimiter: NewLimiter(300), 27 | refreshMovie: NewLimiter(60), 28 | refreshTVShow: NewLimiter(60), 29 | }, 30 | Files: &Files{}, 31 | XBMC: &XBMC{}, 32 | } 33 | 34 | go Rpc.ConsumerRefreshTask() 35 | go Rpc.ConsumerScanTask() 36 | 37 | httpClient = &http.Client{ 38 | Timeout: time.Duration(config.Kodi.Timeout) * time.Second, 39 | Transport: &http.Transport{}, 40 | } 41 | } 42 | 43 | func (r *JsonRpc) Ping() bool { 44 | _, err := r.request(&JsonRpcRequest{Method: "JSONRPC.Ping"}) 45 | if err != nil { 46 | utils.Logger.WarningF("ping kodi err: %v", err) 47 | } 48 | return err == nil 49 | } 50 | 51 | // 发送json rpc请求 52 | func (r *JsonRpc) request(rpcReq *JsonRpcRequest) ([]byte, error) { 53 | if rpcReq.JsonRpc == "" { 54 | rpcReq.JsonRpc = "2.0" 55 | } 56 | 57 | if rpcReq.Id == "" { 58 | rpcReq.Id = strconv.FormatInt(time.Now().UnixNano(), 10) 59 | } 60 | 61 | jsonBytes, err := json.Marshal(rpcReq) 62 | utils.Logger.DebugF("request kodi: %s", jsonBytes) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | req, err := http.NewRequest(http.MethodPost, config.Kodi.JsonRpc, bytes.NewReader(jsonBytes)) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | req.SetBasicAuth(config.Kodi.Username, config.Kodi.Password) 73 | req.Header.Set("Content-Type", "application/json") 74 | 75 | resp, err := httpClient.Do(req) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | defer func(Body io.ReadCloser) { 81 | err := Body.Close() 82 | if err != nil { 83 | utils.Logger.WarningF("request kodi closeBody err: %v", err) 84 | } 85 | }(resp.Body) 86 | 87 | if resp.StatusCode != 200 { 88 | return nil, errors.New(resp.Status) 89 | } 90 | 91 | return io.ReadAll(resp.Body) 92 | } 93 | -------------------------------------------------------------------------------- /kodi/kodi_struct.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type TaskRefresh int 8 | 9 | const ( 10 | TaskSep = "|-|" 11 | TaskRefreshTVShow TaskRefresh = 1 12 | TaskRefreshEpisode TaskRefresh = 2 13 | TaskRefreshMovie TaskRefresh = 3 14 | TaskRefreshMusicVideo TaskRefresh = 4 15 | ) 16 | 17 | type JsonRpc struct { 18 | refreshQueue map[string]struct{} 19 | refreshLock *sync.RWMutex 20 | scanQueue map[string]struct{} 21 | scanLock *sync.RWMutex 22 | VideoLibrary *VideoLibrary 23 | Files *Files 24 | XBMC *XBMC 25 | } 26 | 27 | // JsonRpcRequest JsonRpc 请求参数 28 | type JsonRpcRequest struct { 29 | Id string `json:"id"` 30 | JsonRpc string `json:"jsonrpc"` 31 | Method string `json:"method"` 32 | Params interface{} `json:"params,omitempty"` 33 | } 34 | 35 | // JsonRpcResponse JsonRpc 返回参数 36 | type JsonRpcResponse struct { 37 | Id string `json:"id"` 38 | JsonRpc string `json:"jsonrpc"` 39 | Result map[string]interface{} `json:"result"` 40 | } 41 | 42 | type Limits struct { 43 | Start int `json:"start"` 44 | End int `json:"end"` 45 | } 46 | 47 | type LimitsResult struct { 48 | Start int `json:"start"` 49 | End int `json:"end"` 50 | Total int `json:"total"` 51 | } 52 | 53 | type Sort struct { 54 | Order string `json:"order"` 55 | Method string `json:"method"` 56 | IgnoreArticle bool `json:"ignorearticle"` 57 | } 58 | 59 | type Filter struct { 60 | Field string `json:"field"` 61 | Operator string `json:"operator"` 62 | Value string `json:"value"` 63 | } 64 | -------------------------------------------------------------------------------- /kodi/limiter.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type Limiter struct { 9 | lock *sync.Map 10 | second int 11 | } 12 | 13 | func NewLimiter(second int) *Limiter { 14 | return &Limiter{ 15 | lock: &sync.Map{}, 16 | second: second, 17 | } 18 | } 19 | 20 | func (l *Limiter) take() bool { 21 | var t time.Time 22 | value, ok := l.lock.Load("time") 23 | if !ok { 24 | t = time.Now().Add(time.Second * time.Duration(-l.second)) 25 | } else { 26 | t = value.(time.Time) 27 | } 28 | 29 | if t.Add(time.Second * time.Duration(l.second)).Before(time.Now()) { 30 | l.lock.Store("time", time.Now()) 31 | return true 32 | } 33 | 34 | return false 35 | } 36 | -------------------------------------------------------------------------------- /kodi/video_library.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | ) 7 | 8 | // Scan 扫描媒体库 9 | func (vl *VideoLibrary) Scan(directory string, showDialogs bool) bool { 10 | _, err := Rpc.request(&JsonRpcRequest{ 11 | Method: "VideoLibrary.Scan", 12 | Params: &ScanRequest{ 13 | Directory: directory, 14 | ShowDialogs: showDialogs, 15 | }, 16 | }) 17 | 18 | return err == nil 19 | } 20 | 21 | // IsScanning 是否正在扫描 22 | func (vl *VideoLibrary) IsScanning() bool { 23 | info := Rpc.XBMC.GetInfoBooleans([]string{"Library.IsScanningVideo"}) 24 | return info != nil && info["Library.IsScanningVideo"] 25 | } 26 | 27 | // GetMovies Retrieve all movies 28 | func (vl *VideoLibrary) GetMovies(req *GetMoviesRequest) *GetMoviesResponse { 29 | body, err := Rpc.request(&JsonRpcRequest{Method: "VideoLibrary.GetMovies", Params: req}) 30 | if len(body) == 0 { 31 | return nil 32 | } 33 | 34 | resp := &JsonRpcResponse{} 35 | err = json.Unmarshal(body, resp) 36 | if err != nil { 37 | return nil 38 | } 39 | 40 | if resp != nil && resp.Result != nil { 41 | jsonBytes, _ := json.Marshal(resp.Result) 42 | 43 | moviesResp := &GetMoviesResponse{} 44 | _ = json.Unmarshal(jsonBytes, moviesResp) 45 | 46 | return moviesResp 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // RefreshMovie Refresh the given movie in the library 53 | func (vl *VideoLibrary) RefreshMovie(movieId int) bool { 54 | 55 | _, err := Rpc.request(&JsonRpcRequest{ 56 | Method: "VideoLibrary.RefreshMovie", 57 | Params: &RefreshMovieRequest{ 58 | MovieId: movieId, 59 | IgnoreNfo: false, 60 | }, 61 | }) 62 | 63 | return err == nil 64 | } 65 | 66 | // GetTVShowsByField 自定义根据字段搜索,例如:GetTVShowsByField("title", "is", "去有风的地方") 67 | // TODO 根据名字获取可能有重复,可等待后续使用uniqueId搜索:https://github.com/xbmc/xbmc/pull/22498 68 | func (vl *VideoLibrary) GetTVShowsByField(field, operator, value string) *GetTVShowsResponse { 69 | req := &GetTVShowsRequest{ 70 | Filter: &Filter{ 71 | Field: field, 72 | Operator: operator, 73 | Value: value, 74 | }, 75 | Limit: &Limits{ 76 | Start: 0, 77 | End: 5, 78 | }, 79 | Properties: []string{"title", "originaltitle", "year", "file"}, 80 | } 81 | 82 | body, err := Rpc.request(&JsonRpcRequest{Method: "VideoLibrary.GetTVShows", Params: req}) 83 | if err != nil { 84 | utils.Logger.WarningF("GetTVShowsByField(%s, %s, %s) err: %v", field, operator, value, err) 85 | return nil 86 | } 87 | 88 | resp := &JsonRpcResponse{} 89 | _ = json.Unmarshal(body, resp) 90 | if resp != nil && resp.Result != nil { 91 | jsonBytes, _ := json.Marshal(resp.Result) 92 | 93 | moviesResp := &GetTVShowsResponse{} 94 | _ = json.Unmarshal(jsonBytes, moviesResp) 95 | 96 | return moviesResp 97 | } 98 | 99 | return nil 100 | } 101 | 102 | // GetTVShows Retrieve all tv shows 103 | func (vl *VideoLibrary) GetTVShows(req *GetTVShowsRequest) *GetTVShowsResponse { 104 | body, err := Rpc.request(&JsonRpcRequest{Method: "VideoLibrary.GetTVShows", Params: req}) 105 | if len(body) == 0 { 106 | return nil 107 | } 108 | 109 | resp := &JsonRpcResponse{} 110 | err = json.Unmarshal(body, resp) 111 | if err != nil { 112 | panic(err) 113 | } 114 | 115 | if resp != nil && resp.Result != nil { 116 | jsonBytes, _ := json.Marshal(resp.Result) 117 | 118 | moviesResp := &GetTVShowsResponse{} 119 | _ = json.Unmarshal(jsonBytes, moviesResp) 120 | 121 | return moviesResp 122 | } 123 | 124 | return nil 125 | } 126 | 127 | // RefreshTVShow Refresh the given tv show in the library 128 | func (vl *VideoLibrary) RefreshTVShow(tvShowId int) bool { 129 | _, err := Rpc.request(&JsonRpcRequest{ 130 | Method: "VideoLibrary.RefreshTVShow", 131 | Params: &RefreshTVShowRequest{ 132 | TvShowId: tvShowId, 133 | IgnoreNfo: false, 134 | RefreshEpisodes: false, 135 | }, 136 | }) 137 | 138 | return err == nil 139 | } 140 | 141 | // RefreshEpisode 刷新剧集信息 142 | // https://kodi.wiki/view/JSON-RPC_API/v13#VideoLibrary.RefreshEpisode 143 | func (vl *VideoLibrary) RefreshEpisode(episodeId int) bool { 144 | _, err := Rpc.request(&JsonRpcRequest{ 145 | Method: "VideoLibrary.RefreshEpisode", 146 | Params: &RefreshEpisodeRequest{ 147 | EpisodeId: episodeId, 148 | }, 149 | }) 150 | 151 | return err == nil 152 | } 153 | 154 | // Clean 清理资料库 155 | func (vl *VideoLibrary) Clean(directory string, showDialogs bool) bool { 156 | _, err := Rpc.request(&JsonRpcRequest{ 157 | Method: "VideoLibrary.Clean", 158 | Params: &CleanRequest{ 159 | Directory: directory, 160 | ShowDialogs: showDialogs, 161 | Content: "video", 162 | }, 163 | }) 164 | 165 | return err == nil 166 | } 167 | 168 | // GetEpisodes 获取电视剧剧集列表 169 | func (vl *VideoLibrary) GetEpisodes(tvShowId, season int, filter *Filter) ([]*Episode, error) { 170 | bytes, err := Rpc.request(&JsonRpcRequest{ 171 | Method: "VideoLibrary.GetEpisodes", 172 | Params: &GetEpisodesRequest{ 173 | TvShowId: tvShowId, 174 | Season: season, 175 | Filter: filter, 176 | Properties: EpisodeFields, 177 | }, 178 | }) 179 | 180 | resp := &JsonRpcResponse{} 181 | err = json.Unmarshal(bytes, resp) 182 | if err != nil || resp.Result == nil { 183 | return nil, err 184 | } 185 | 186 | jsonBytes, _ := json.Marshal(resp.Result) 187 | episodesResp := &GetEpisodesResponse{} 188 | err = json.Unmarshal(jsonBytes, episodesResp) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | return episodesResp.Episodes, nil 194 | } 195 | 196 | // SetEpisodeDetails 更新剧集详情 197 | // https://kodi.wiki/view/JSON-RPC_API/v12#VideoLibrary.SetEpisodeDetails 198 | func (vl *VideoLibrary) SetEpisodeDetails(episodeId int, params map[string]interface{}) bool { 199 | params["episodeid"] = episodeId 200 | 201 | bytes, err := Rpc.request(&JsonRpcRequest{ 202 | Method: "VideoLibrary.SetEpisodeDetails", 203 | Params: params, 204 | }) 205 | 206 | resp := &JsonRpcResponse{} 207 | _ = json.Unmarshal(bytes, resp) 208 | 209 | return err == nil 210 | } 211 | 212 | // SetMovieDetails 更新电影信息 213 | // https://kodi.wiki/view/JSON-RPC_API/v12#VideoLibrary.SetMovieDetails 214 | func (vl *VideoLibrary) SetMovieDetails(movieId int, params map[string]interface{}) bool { 215 | params["movieid"] = movieId 216 | 217 | bytes, err := Rpc.request(&JsonRpcRequest{ 218 | Method: "VideoLibrary.SetMovieDetails", 219 | Params: params, 220 | }) 221 | 222 | resp := &JsonRpcResponse{} 223 | _ = json.Unmarshal(bytes, resp) 224 | 225 | return err == nil 226 | } 227 | -------------------------------------------------------------------------------- /kodi/video_library_clean.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | ) 7 | 8 | func (r *JsonRpc) AddCleanTask(directory string) { 9 | if !config.Kodi.Enable { 10 | return 11 | } 12 | 13 | utils.Logger.DebugF("AddCleanTask %s", directory) 14 | } 15 | -------------------------------------------------------------------------------- /kodi/video_library_refresh.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // AddRefreshTask 添加刷新数据任务 13 | func (r *JsonRpc) AddRefreshTask(task TaskRefresh, value string) { 14 | if !config.Kodi.Enable { 15 | return 16 | } 17 | 18 | utils.Logger.DebugF("AddRefreshTask %d %s", task, value) 19 | 20 | r.refreshLock.Lock() 21 | defer r.refreshLock.Unlock() 22 | 23 | taskName := fmt.Sprintf("%.02d|-|%s", task, value) 24 | if _, ok := r.refreshQueue[taskName]; !ok { 25 | r.refreshQueue[taskName] = struct{}{} 26 | } 27 | 28 | return 29 | } 30 | 31 | // ConsumerRefreshTask 消费刷新数据任务 32 | func (r *JsonRpc) ConsumerRefreshTask() { 33 | if !config.Kodi.Enable { 34 | return 35 | } 36 | 37 | for { 38 | if len(r.refreshQueue) == 0 || !r.Ping() || r.VideoLibrary.IsScanning() { 39 | time.Sleep(time.Second * 30) 40 | continue 41 | } 42 | 43 | for queue := range r.refreshQueue { 44 | _task, _ := strconv.Atoi(queue[0:2]) 45 | task := TaskRefresh(_task) 46 | 47 | r.refreshLock.Lock() 48 | 49 | switch task { 50 | case TaskRefreshTVShow: 51 | r.RefreshShows(queue[5:]) 52 | break 53 | case TaskRefreshEpisode: 54 | r.RefreshEpisode(queue[5:]) 55 | break 56 | case TaskRefreshMovie: 57 | r.RefreshMovie(queue[5:]) 58 | break 59 | case TaskRefreshMusicVideo: 60 | r.RefreshMusicVideo(queue[5:]) 61 | } 62 | 63 | delete(r.refreshQueue, queue) 64 | r.refreshLock.Unlock() 65 | } 66 | } 67 | } 68 | 69 | func (r *JsonRpc) RefreshMusicVideo(s string) bool { 70 | // TODO 71 | return true 72 | } 73 | 74 | func (r *JsonRpc) RefreshMovie(name string) bool { 75 | kodiMoviesReq := &GetMoviesRequest{ 76 | Filter: &Filter{ 77 | Field: "originaltitle", 78 | Operator: "is", 79 | Value: name, 80 | }, 81 | Limit: &Limits{ 82 | Start: 0, 83 | End: 5, 84 | }, 85 | Properties: MovieFields, 86 | } 87 | 88 | kodiMoviesResp := r.VideoLibrary.GetMovies(kodiMoviesReq) 89 | if kodiMoviesResp == nil || kodiMoviesResp.Limits.Total == 0 { 90 | r.VideoLibrary.Scan("", true) // 同剧集,新电影,刷新变扫描库 91 | return false 92 | } 93 | 94 | for _, item := range kodiMoviesResp.Movies { 95 | if item.LastPlayed == "" && item.PlayCount == 0 { 96 | utils.Logger.DebugF("find movie by name: %s, refresh detail", item.Title) 97 | r.VideoLibrary.RefreshMovie(item.MovieId) 98 | } 99 | } 100 | 101 | return true 102 | } 103 | 104 | func (r *JsonRpc) RefreshShows(name string) bool { 105 | kodiShowsResp := r.VideoLibrary.GetTVShowsByField("originaltitle", "contains", name) 106 | if kodiShowsResp == nil || kodiShowsResp.Limits.Total == 0 { 107 | r.VideoLibrary.Scan("", true) // 新剧集,刷新变扫描库,不知道在Kodi的路径所以路径为空 108 | return false 109 | } 110 | 111 | for _, item := range kodiShowsResp.TvShows { 112 | utils.Logger.DebugF("refresh tv shows %s", item.Title) 113 | r.VideoLibrary.RefreshTVShow(item.TvShowId) 114 | } 115 | 116 | return true 117 | } 118 | 119 | func (r *JsonRpc) RefreshEpisode(taskVal string) bool { 120 | taskInfo := strings.Split(taskVal, "|-|") 121 | if len(taskInfo) != 3 { 122 | return false 123 | } 124 | 125 | kodiShowsResp := r.VideoLibrary.GetTVShowsByField("originaltitle", "contains", taskInfo[0]) 126 | if kodiShowsResp == nil || kodiShowsResp.Limits.Total == 0 { 127 | return false 128 | } 129 | 130 | for _, item := range kodiShowsResp.TvShows { 131 | filter := &Filter{ 132 | Field: "episode", 133 | Operator: "is", 134 | Value: taskInfo[2], 135 | } 136 | season, err := strconv.Atoi(taskInfo[1]) 137 | if err != nil || season == 0 { 138 | continue 139 | } 140 | 141 | episodes, err := r.VideoLibrary.GetEpisodes(item.TvShowId, season, filter) 142 | if err != nil { 143 | continue 144 | } 145 | 146 | // 新增的剧集,需要扫描库 147 | if episodes == nil || len(episodes) == 0 { 148 | r.AddScanTask(item.File) 149 | continue 150 | } 151 | 152 | for _, episode := range episodes { 153 | if episode.PlayCount == 0 && episode.LastPlayed == "" { 154 | utils.Logger.DebugF("refresh tv shows %s episode %d", item.Title, episode.Episode) 155 | r.VideoLibrary.RefreshEpisode(episode.EpisodeId) 156 | } 157 | } 158 | } 159 | 160 | return true 161 | } 162 | -------------------------------------------------------------------------------- /kodi/video_library_scan.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func (r *JsonRpc) AddScanTask(directory string) { 11 | if !config.Kodi.Enable { 12 | return 13 | } 14 | 15 | utils.Logger.DebugF("AddScanTask %s", directory) 16 | if directory != "" { 17 | //directory = filepath.Clean(directory) 18 | sources := r.Files.GetSources("video") 19 | if sources != nil { 20 | for _, item := range sources { 21 | if strings.Contains(item.File, directory) || strings.Contains(directory, item.File) { 22 | directory = item.File 23 | break 24 | } 25 | } 26 | } 27 | } 28 | 29 | r.scanLock.Lock() 30 | defer r.scanLock.Unlock() 31 | 32 | if _, ok := r.scanQueue[directory]; !ok { 33 | r.scanQueue[directory] = struct{}{} 34 | } 35 | 36 | return 37 | } 38 | 39 | func (r *JsonRpc) ConsumerScanTask() { 40 | if !config.Kodi.Enable { 41 | return 42 | } 43 | 44 | for { 45 | if len(r.scanQueue) == 0 || !r.Ping() || r.VideoLibrary.IsScanning() { 46 | time.Sleep(time.Second * 30) 47 | continue 48 | } 49 | 50 | if !r.VideoLibrary.scanLimiter.take() { 51 | time.Sleep(time.Second * 30) 52 | continue 53 | } 54 | 55 | for directory := range r.scanQueue { 56 | r.scanLock.Lock() 57 | 58 | r.VideoLibrary.Scan(directory, true) 59 | 60 | delete(r.scanQueue, directory) 61 | r.scanLock.Unlock() 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /kodi/video_library_struct.go: -------------------------------------------------------------------------------- 1 | package kodi 2 | 3 | var EpisodeFields = []string{ 4 | "title", 5 | "plot", 6 | "votes", 7 | "rating", 8 | "writer", 9 | "firstaired", 10 | "playcount", 11 | "runtime", 12 | "director", 13 | "productioncode", 14 | "season", 15 | "episode", 16 | "originaltitle", 17 | "showtitle", 18 | "cast", 19 | "streamdetails", 20 | "lastplayed", 21 | "fanart", 22 | "thumbnail", 23 | "file", 24 | "resume", 25 | "tvshowid", 26 | "dateadded", 27 | "uniqueid", 28 | "art", 29 | "specialsortseason", 30 | "specialsortepisode", 31 | "userrating", 32 | "seasonid", 33 | "ratings", 34 | } 35 | 36 | var MovieFields = []string{ 37 | "title", 38 | "genre", 39 | "year", 40 | "rating", 41 | "director", 42 | "trailer", 43 | "tagline", 44 | "plot", 45 | "plotoutline", 46 | "originaltitle", 47 | "lastplayed", 48 | "playcount", 49 | "writer", 50 | "studio", 51 | "mpaa", 52 | "cast", 53 | "country", 54 | "imdbnumber", 55 | "runtime", 56 | "set", 57 | "showlink", 58 | "streamdetails", 59 | "top250", 60 | "votes", 61 | "fanart", 62 | "thumbnail", 63 | "file", 64 | "sorttitle", 65 | "resume", 66 | "setid", 67 | "dateadded", 68 | "tag", 69 | "art", 70 | "userrating", 71 | "ratings", 72 | "premiered", 73 | "uniqueid", 74 | } 75 | 76 | type VideoLibrary struct { 77 | scanLimiter *Limiter 78 | refreshMovie *Limiter 79 | refreshTVShow *Limiter 80 | } 81 | 82 | // RefreshMovieRequest 刷新电影请求参数 83 | type RefreshMovieRequest struct { 84 | MovieId int `json:"movieid"` 85 | IgnoreNfo bool `json:"ignorenfo"` 86 | Title string `json:"title"` 87 | } 88 | 89 | // RefreshTVShowRequest 刷新电视剧请求参数 90 | type RefreshTVShowRequest struct { 91 | TvShowId int `json:"tvshowid"` 92 | IgnoreNfo bool `json:"ignorenfo"` 93 | RefreshEpisodes bool `json:"refreshepisodes"` 94 | Title string `json:"title"` 95 | } 96 | 97 | // GetMoviesRequest 获取电影请求参数 98 | type GetMoviesRequest struct { 99 | Filter *Filter `json:"filter"` 100 | Limit *Limits `json:"limits"` 101 | Properties []string `json:"properties"` 102 | } 103 | 104 | // GetMoviesResponse 获取电影返回参数 105 | type GetMoviesResponse struct { 106 | Limits LimitsResult `json:"limits"` 107 | Movies []MovieDetails `json:"movies"` 108 | } 109 | 110 | // GetTVShowsRequest 获取电视剧请求参数 111 | type GetTVShowsRequest struct { 112 | Filter *Filter `json:"filter"` 113 | Limit *Limits `json:"limits"` 114 | Properties []string `json:"properties"` 115 | } 116 | 117 | // GetTVShowsResponse 获取电视剧返回参数 118 | type GetTVShowsResponse struct { 119 | Limits LimitsResult `json:"limits"` 120 | TvShows []*TvShowDetails `json:"tvshows"` 121 | } 122 | 123 | // ScanRequest 扫描视频媒体库请求参数 124 | type ScanRequest struct { 125 | Directory string `json:"directory"` 126 | ShowDialogs bool `json:"showdialogs"` 127 | } 128 | 129 | // CleanRequest 清理资料库 130 | type CleanRequest struct { 131 | ShowDialogs bool `json:"showdialogs"` 132 | Content string `json:"content"` 133 | Directory string `json:"directory"` 134 | } 135 | 136 | type TvShowDetails struct { 137 | TvShowId int `json:"tvshowid"` 138 | Title string `json:"title"` 139 | OriginalTitle string `json:"originaltitle"` 140 | File string `json:"file"` 141 | } 142 | 143 | type MovieDetails struct { 144 | MovieId int `json:"movieid"` 145 | Title string `json:"title"` 146 | OriginalTitle string `json:"originaltitle"` 147 | Label string `json:"label"` 148 | Year int `json:"year"` 149 | PlayCount int `json:"playcount"` 150 | LastPlayed string `json:"lastplayed"` 151 | UniqueId UniqueId `json:"uniqueid"` 152 | } 153 | 154 | type UniqueId struct { 155 | Imdb string `json:"imdb"` 156 | Tvdb string `json:"tvdb"` 157 | Tmdb string `json:"tmdb"` 158 | } 159 | 160 | type GetEpisodesRequest struct { 161 | TvShowId int `json:"tvshowid"` 162 | Season int `json:"season"` 163 | Filter *Filter `json:"filter"` 164 | Properties []string `json:"properties"` 165 | } 166 | 167 | type GetEpisodesResponse struct { 168 | Limits LimitsResult `json:"limits"` 169 | Episodes []*Episode `json:"episodes"` 170 | } 171 | 172 | type Episode struct { 173 | EpisodeId int `json:"episodeid"` 174 | TvShowId int `json:"tvshowid"` 175 | Label string `json:"label"` 176 | LastPlayed string `json:"lastplayed"` 177 | PlayCount int `json:"playcount"` 178 | Episode int `json:"episode"` 179 | } 180 | 181 | type RefreshEpisodeRequest struct { 182 | EpisodeId int `json:"episodeid"` 183 | } 184 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/collector" 5 | "fengqi/kodi-metadata-tmdb-cli/common/memcache" 6 | "fengqi/kodi-metadata-tmdb-cli/config" 7 | "fengqi/kodi-metadata-tmdb-cli/ffmpeg" 8 | "fengqi/kodi-metadata-tmdb-cli/kodi" 9 | "fengqi/kodi-metadata-tmdb-cli/tmdb" 10 | "fengqi/kodi-metadata-tmdb-cli/utils" 11 | "flag" 12 | "fmt" 13 | "runtime" 14 | ) 15 | 16 | var ( 17 | configFile string 18 | version bool 19 | buildVersion = "dev-master" 20 | ) 21 | 22 | func init() { 23 | flag.StringVar(&configFile, "config", "./config.json", "config file") 24 | flag.BoolVar(&version, "version", false, "display version") 25 | flag.Parse() 26 | } 27 | 28 | func main() { 29 | if version { 30 | fmt.Printf("version: %s, build with: %s\n", buildVersion, runtime.Version()) 31 | return 32 | } 33 | 34 | config.LoadConfig(configFile) 35 | 36 | memcache.InitCache() 37 | utils.InitLogger() 38 | tmdb.InitTmdb() 39 | kodi.InitKodi() 40 | ffmpeg.InitFfmpeg() 41 | 42 | collector.Run() 43 | } 44 | -------------------------------------------------------------------------------- /media_file/media_file.go: -------------------------------------------------------------------------------- 1 | package media_file 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/utils" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | trailerCompile, _ = regexp.Compile("(?i).*[\\[\\]\\(\\)_.-]+trailer[\\[\\]\\(\\)_.-]?(\\d)*$") 12 | sampleCompile, _ = regexp.Compile("(?i).*[\\[\\]\\(\\)_.-]+sample[\\[\\]\\(\\)_.-]?$") 13 | dvdCompile, _ = regexp.Compile("(video_ts|vts_\\d\\d_\\d)\\.(vob|bup|ifo)") 14 | bluRayCompile, _ = regexp.Compile("(index\\.bdmv|movieobject\\.bdmv|\\d{5}\\.m2ts|\\d{5}\\.clpi|\\d{5}\\.mpls)") 15 | ) 16 | 17 | // NewMediaFile 实例化媒体类型 18 | func NewMediaFile(path, filename string, videoType VideoType) *MediaFile { 19 | if filename[0:1] == "." { 20 | return nil 21 | } 22 | 23 | mediaType := parseMediaType(path, filename) 24 | 25 | return &MediaFile{ 26 | Path: path, 27 | Filename: filename, 28 | MediaType: mediaType, 29 | VideoType: videoType, 30 | Suffix: filepath.Ext(filename), 31 | } 32 | } 33 | 34 | // MediaType 解析文件类型 35 | func parseMediaType(path, filename string) MediaType { 36 | folderName := strings.ToLower(filepath.Base(path)) 37 | ext := filepath.Ext(filename) 38 | basename := strings.ToLower(strings.Replace(filename, ext, "", 1)) 39 | 40 | if folderName == "extras" || folderName == "extra" { 41 | return EXTRA 42 | } 43 | 44 | if ext == ".nfo" { 45 | return NFO 46 | } 47 | 48 | if ext == ".vsmeta" { 49 | return VSMETA 50 | } 51 | 52 | // 图片 53 | for _, v := range ArtworkFileTypes { // todo map 54 | if strings.Contains(filename, v) { 55 | return GRAPHIC 56 | } 57 | } 58 | 59 | for _, v := range AudioFileTypes { 60 | if strings.Contains(filename, v) { 61 | return AUDIO 62 | } 63 | } 64 | 65 | for _, v := range SubtitleFileTypes { 66 | if strings.Contains(filename, v) { 67 | return SUBTITLE 68 | } 69 | } 70 | 71 | if isDiscFile(filename, path) { 72 | return DISC 73 | } 74 | 75 | for _, v := range VideoFileTypes { 76 | if strings.Contains(filename, v) { 77 | if basename == "movie-trailer" || 78 | folderName == "trailer" || 79 | folderName == "trailers" || 80 | trailerCompile.FindString(basename) != "" { 81 | return TRAILER 82 | } 83 | 84 | if basename == "sample" || folderName == "sample" || sampleCompile.FindString(basename) != "" { 85 | return SAMPLE 86 | } 87 | 88 | return VIDEO 89 | } 90 | } 91 | 92 | if isDiscFile(filename, folderName) { 93 | return VIDEO 94 | } 95 | 96 | if ext == ".txt" { 97 | return VSMETA 98 | } 99 | 100 | return UNKNOWN 101 | } 102 | 103 | // 是否是光盘文件 104 | func isDiscFile(filename, path string) bool { 105 | return isDVDFile(filename, path) || isBluRayFile(filename, path) || isHDDVDFile(filename, path) 106 | } 107 | 108 | // 是否是DVD光盘文件 109 | func isDVDFile(filename, path string) bool { 110 | if filename == "VIDEO_TS" || utils.EndsWith(path, "VIDEO_TS") { 111 | return true 112 | } 113 | 114 | return dvdCompile.FindString(filename) != "" 115 | } 116 | 117 | // 是否是蓝光文件 118 | func isBluRayFile(filename, path string) bool { 119 | if filename == "BDMV" || utils.EndsWith(path, "BDMV") { 120 | return true 121 | } 122 | 123 | return bluRayCompile.FindString(filename) != "" 124 | } 125 | 126 | // 是否是HD DVD文件 127 | func isHDDVDFile(filename, path string) bool { 128 | return filename == "HVDVD_TS" || utils.EndsWith(path, "HVDVD_TS") 129 | } 130 | -------------------------------------------------------------------------------- /media_file/struct.go: -------------------------------------------------------------------------------- 1 | package media_file 2 | 3 | import "strings" 4 | 5 | type MediaFile struct { 6 | Path string // 完整路径 7 | Filename string // 文件名 8 | Suffix string // 后缀 9 | MediaType MediaType // 文件类型 10 | VideoType VideoType // 视频类型 11 | } 12 | 13 | type VideoType int 14 | 15 | type MediaType int 16 | 17 | const ( 18 | Movies = iota + 1 19 | TvShows 20 | MusicVideo 21 | ) 22 | 23 | const ( 24 | UNKNOWN MediaType = iota 25 | VIDEO 26 | TRAILER 27 | SAMPLE 28 | AUDIO 29 | SUBTITLE 30 | NFO 31 | POSTER 32 | FANART 33 | BANNER 34 | CLEARART 35 | DISC 36 | LOGO 37 | CLEARLOGO 38 | THUMB 39 | CHARACTERART 40 | KEYART 41 | SEASON_POSTER 42 | SEASON_FANART 43 | SEASON_BANNER 44 | SEASON_THUMB 45 | EXTRAFANART 46 | EXTRATHUMB 47 | EXTRA 48 | GRAPHIC 49 | MEDIAINFO 50 | VSMETA 51 | THEME 52 | TEXT 53 | DOUBLE_EXT 54 | ) 55 | 56 | var ( 57 | VIDEO_TS = "VIDEO_TS" 58 | BDMV = "BDMV" 59 | HVDVD_TS = "HVDVD_TS" 60 | 61 | ArtworkFileTypes = []string{ 62 | "jpg", "jpeg,", "png", "tbn", "gif", "bmp", "webp", 63 | } 64 | VideoFileTypes = []string{ 65 | ".3gp", ".asf", ".asx", ".avc", ".avi", ".bdmv", ".bin", ".bivx", ".braw", ".dat", ".divx", ".dv", ".dvr-ms", 66 | ".disc", ".evo", ".fli", ".flv", ".h264", ".ifo", ".img", ".iso", ".mts", ".mt2s", ".m2ts", ".m2v", ".m4v", 67 | ".mkv", ".mk3d", ".mov", ".mp4", ".mpeg", ".mpg", ".nrg", ".nsv", ".nuv", ".ogm", ".pva", ".qt", ".rm", ".rmvb", 68 | ".strm", ".svq3", ".ts", ".ty", ".viv", ".vob", ".vp3", ".wmv", ".webm", ".xvid", 69 | } 70 | AudioFileTypes = []string{ 71 | ".a52", ".aa3", ".aac", ".ac3", ".adt", ".adts", ".aif", ".aiff", ".alac", ".ape", ".at3", ".atrac", ".au", 72 | ".dts", ".flac", ".m4a", ".m4b", ".m4p", ".mid", ".midi", ".mka", ".mp3", ".mpa", ".mlp", ".oga", ".ogg", 73 | ".pcm", ".ra", ".ram", ".tta", ".thd", ".wav", ".wave", ".wma", 74 | } 75 | SubtitleFileTypes = []string{ 76 | ".aqt", ".cvd", ".dks", ".jss", ".sub", ".sup", ".ttxt", ".mpl", ".pjs", ".psb", ".rt", ".srt", ".smi", 77 | ".ssf", ".ssa", ".svcd", ".usf", ".ass", ".pgs", ".vobsub", 78 | } 79 | ) 80 | 81 | // IsNFO 是否是NFO文件 82 | func (mf *MediaFile) IsNFO() bool { 83 | return mf.MediaType == NFO 84 | } 85 | 86 | // IsVideo 是否是视频 87 | func (mf *MediaFile) IsVideo() bool { 88 | return mf.MediaType == VIDEO 89 | } 90 | 91 | // IsBluRay 是否是蓝光目录 92 | func (mf *MediaFile) IsBluRay() bool { 93 | return mf.MediaType == DISC && (mf.Filename == BDMV || mf.Filename == HVDVD_TS) 94 | } 95 | 96 | // IsDvd 是否是DVD目录 97 | func (mf *MediaFile) IsDvd() bool { 98 | return mf.IsDisc() && (mf.Filename == VIDEO_TS || mf.Filename == "DVD") 99 | } 100 | 101 | // IsDisc 判断是否是光盘目录 102 | func (mf *MediaFile) IsDisc() bool { 103 | return mf.MediaType == DISC 104 | } 105 | 106 | // PathWithoutSuffix 完整路径,去掉后缀,用于生成NFO、海报等 107 | func (mf *MediaFile) PathWithoutSuffix() string { 108 | return strings.Replace(mf.Path, mf.Suffix, "", 1) 109 | } 110 | -------------------------------------------------------------------------------- /movies/nfo.go: -------------------------------------------------------------------------------- 1 | package movies 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/tmdb" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "os" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // 保存NFO文件 13 | func (m *Movie) saveToNfo(detail *tmdb.MovieDetail, mode int) error { 14 | nfoFile := m.getNfoFile(mode) 15 | if nfoFile == "" { 16 | utils.Logger.InfoF("movie nfo empty %v", m) 17 | return nil 18 | } 19 | 20 | utils.Logger.InfoF("save movie nfo to: %s", nfoFile) 21 | 22 | genre := make([]string, 0) 23 | for _, item := range detail.Genres { 24 | genre = append(genre, item.Name) 25 | } 26 | 27 | studio := make([]string, 0) 28 | for _, item := range detail.ProductionCompanies { 29 | studio = append(studio, item.Name) 30 | } 31 | 32 | rating := make([]Rating, 1) 33 | rating[0] = Rating{ 34 | Name: "tmdb", 35 | Max: 10, 36 | Value: detail.VoteAverage, 37 | Votes: detail.VoteCount, 38 | } 39 | 40 | actor := make([]Actor, 0) 41 | if detail.Credits != nil { 42 | for _, item := range detail.Credits.Cast { 43 | if item.ProfilePath == "" { 44 | continue 45 | } 46 | 47 | actor = append(actor, Actor{ 48 | Name: item.Name, 49 | Role: item.Character, 50 | Order: item.Order, 51 | Thumb: tmdb.Api.GetImageW500(item.ProfilePath), 52 | SortOrder: item.CastId, 53 | }) 54 | } 55 | } 56 | 57 | mpaa := "NR" 58 | contentRating := strings.ToUpper(config.Tmdb.Rating) 59 | if detail.Releases.Countries != nil && len(detail.Releases.Countries) > 0 { 60 | mpaa = detail.Releases.Countries[0].Certification 61 | for _, item := range detail.Releases.Countries { 62 | if strings.ToUpper(item.ISO31661) == contentRating { 63 | mpaa = item.Certification 64 | break 65 | } 66 | } 67 | } 68 | 69 | var fanArt *FanArt 70 | if detail.BackdropPath != "" { 71 | fanArt = &FanArt{ 72 | Thumb: []MovieThumb{ 73 | { 74 | Preview: tmdb.Api.GetImageW500(detail.BackdropPath), 75 | }, 76 | }, 77 | } 78 | } 79 | 80 | year := "" 81 | if detail.ReleaseDate != "" { 82 | year = detail.ReleaseDate[:4] 83 | } 84 | 85 | country := make([]string, 0) 86 | for _, item := range detail.ProductionCountries { 87 | country = append(country, item.Name) // todo 使用 iso_3166_1 匹配中文 88 | } 89 | 90 | languages := make([]string, 0) 91 | for _, item := range detail.SpokenLanguages { 92 | languages = append(languages, item.Name) // todo 使用 iso_639_1 匹配中文 93 | } 94 | 95 | top := &MovieNfo{ 96 | Title: detail.Title, 97 | OriginalTitle: detail.OriginalTitle, 98 | SortTitle: detail.Title, 99 | Plot: detail.Overview, 100 | UniqueId: UniqueId{ 101 | Default: true, 102 | Type: "tmdb", 103 | Value: strconv.Itoa(detail.Id), 104 | }, 105 | Id: detail.Id, 106 | Premiered: detail.ReleaseDate, 107 | Ratings: Ratings{Rating: rating}, 108 | MPaa: mpaa, 109 | Year: year, 110 | Status: detail.Status, 111 | Genre: genre, 112 | Tag: genre, 113 | Country: country, 114 | Languages: languages, 115 | Studio: studio, 116 | UserRating: detail.VoteAverage, 117 | Actor: actor, 118 | FanArt: fanArt, 119 | } 120 | 121 | return utils.SaveNfo(nfoFile, top) 122 | } 123 | 124 | // maybe .nfo 125 | // Kodi比较推荐 .nfo 但是存在一种情况就是,使用inotify监听文件变动,可能电影目录先创建 126 | // 里面的视频文件会迟一点,这个时候 VideoFileName 就会为空,导致NFO写入失败 127 | // 部分资源可能存在 .nfo 且里面写入了一些无用的信息,会产生冲突 128 | // 如果使用 movie.nfo 就不需要考虑这个情况,但是需要打开媒体源的 "电影在以片名命名的单独目录中" 129 | func (m *Movie) getNfoFile(mode int) string { 130 | if m.MediaFile.IsBluRay() { 131 | //if utils.FileExist(m.GetFullDir() + "/MovieObject.bdmv") { 132 | // return m.GetFullDir() + "/MovieObject.nfo" 133 | //} 134 | return m.GetFullDir() + "/index.nfo" 135 | } 136 | 137 | if m.MediaFile.IsDvd() { 138 | return m.GetFullDir() + "/VIDEO_TS/VIDEO_TS.nfo" 139 | } 140 | 141 | //if mode == 2 { // todo movie.nfo作为可选,.nfo为固定生成 142 | // return m.GetFullDir() + "/movie.nfo" 143 | //} 144 | 145 | suffix := utils.IsVideo(m.MediaFile.Filename) 146 | return strings.Replace(m.MediaFile.Path, "."+suffix, "", 1) + ".nfo" 147 | } 148 | 149 | // NfoExist 判断NFO文件是否存在 150 | func (m *Movie) NfoExist(mode int) bool { 151 | nfo := m.getNfoFile(mode) 152 | 153 | if info, err := os.Stat(nfo); err == nil && info.Size() > 0 { 154 | return true 155 | } 156 | 157 | return false 158 | } 159 | -------------------------------------------------------------------------------- /movies/nfo_struct.go: -------------------------------------------------------------------------------- 1 | package movies 2 | 3 | import "encoding/xml" 4 | 5 | // MovieNfo movie.nfo 6 | // 7 | // https://kodi.wiki/view/NFO_files/Movies 8 | // NFO files to be scraped into the movie library are relatively simple and require only a single nfo file per title. 9 | type MovieNfo struct { 10 | XMLName xml.Name `xml:"movie"` 11 | Title string `xml:"title"` 12 | OriginalTitle string `xml:"originaltitle"` 13 | SortTitle string `xml:"sorttitle"` 14 | Ratings Ratings `xml:"ratings"` 15 | UserRating float32 `xml:"userrating"` 16 | Top250 string `xml:"-"` 17 | Outline string `xml:"-"` 18 | Plot string `xml:"plot"` 19 | Tagline string `xml:"-"` 20 | Runtime int `xml:"-"` // Kodi会从视频文件提取,考虑到版本(剪辑版、完整版等)问题,这里不提供 21 | Thumb []Thumb `xml:"-"` 22 | FanArt *FanArt `xml:"fanart"` 23 | MPaa string `xml:"mpaa"` 24 | PlayCount int `xml:"-"` 25 | LastPlayed string `xml:"-"` 26 | Id int `xml:"id"` 27 | UniqueId UniqueId `xml:"uniqueid"` 28 | Genre []string `xml:"genre"` 29 | Tag []string `xml:"tag"` 30 | Set Set `xml:"-"` 31 | Country []string `xml:"country"` 32 | Languages []string `xml:"languages"` 33 | Credits []string `xml:"credits"` 34 | Director []string `xml:"director"` 35 | Premiered string `xml:"premiered"` 36 | Year string `xml:"-"` 37 | Status string `xml:"status"` 38 | Aired string `xml:"-"` 39 | Studio []string `xml:"studio"` 40 | Trailer string `xml:"-"` 41 | FileInfo FileInfo `xml:"-"` 42 | Actor []Actor `xml:"actor"` 43 | ShowLink string `xml:"-"` 44 | Resume Resume `xml:"-"` 45 | DateAdded int `xml:"-"` 46 | } 47 | 48 | type Set struct { 49 | Name string `xml:"name"` 50 | Overview string `xml:"overview"` 51 | } 52 | 53 | type Ratings struct { 54 | Rating []Rating `xml:"rating"` 55 | } 56 | 57 | type Rating struct { 58 | Name string `xml:"name,attr"` 59 | Max int `xml:"max,attr"` 60 | Value float32 `xml:"value"` 61 | Votes int `xml:"votes"` 62 | } 63 | 64 | type FileInfo struct { 65 | StreamDetails StreamDetails `xml:"streamdetails"` 66 | } 67 | 68 | type StreamDetails struct { 69 | Video []Video `xml:"video"` 70 | Audio []Audio `xml:"audio"` 71 | Subtitle []Subtitle `xml:"subtitle"` 72 | } 73 | 74 | type Video struct { 75 | Codec string `xml:"codec"` 76 | Aspect string `xml:"aspect"` 77 | Width int `xml:"width"` 78 | Height int `xml:"height"` 79 | DurationInSeconds int `xml:"durationinseconds"` 80 | StereoMode int `xml:"stereomode"` 81 | } 82 | 83 | type Audio struct { 84 | Codec string `xml:"codec"` 85 | Language string `xml:"language"` 86 | Channels int `xml:"channels"` 87 | } 88 | 89 | type Subtitle struct { 90 | Codec string `xml:"codec"` 91 | Micodec string `xml:"micodec"` 92 | Language string `xml:"language"` 93 | ScanType string `xml:"scantype"` 94 | Default bool `xml:"default"` 95 | Forced bool `xml:"forced"` 96 | } 97 | 98 | type Thumb struct { 99 | Aspect string `xml:"aspect,attr"` 100 | Preview string `xml:"preview,attr"` 101 | } 102 | 103 | type UniqueId struct { 104 | XMLName xml.Name `xml:"uniqueid"` 105 | Default bool `xml:"default,attr"` 106 | Type string `xml:"type,attr"` 107 | Value string `xml:",chardata"` 108 | } 109 | 110 | type Actor struct { 111 | Name string `xml:"name"` 112 | Role string `xml:"role"` 113 | Order int `xml:"order"` 114 | SortOrder int `xml:"sortorder"` 115 | Thumb string `xml:"thumb"` 116 | } 117 | 118 | type FanArt struct { 119 | XMLName xml.Name `xml:"fanart"` 120 | Thumb []MovieThumb `xml:"thumb"` 121 | } 122 | 123 | type MovieThumb struct { 124 | Preview string `xml:"preview,attr"` 125 | } 126 | 127 | type Resume struct { 128 | Position string `xml:"position"` 129 | Total int `xml:"total"` 130 | } 131 | -------------------------------------------------------------------------------- /movies/process.go: -------------------------------------------------------------------------------- 1 | package movies 2 | 3 | import ( 4 | "errors" 5 | "fengqi/kodi-metadata-tmdb-cli/config" 6 | "fengqi/kodi-metadata-tmdb-cli/kodi" 7 | "fengqi/kodi-metadata-tmdb-cli/media_file" 8 | "fengqi/kodi-metadata-tmdb-cli/utils" 9 | "fmt" 10 | "github.com/fengqi/lrace" 11 | "path/filepath" 12 | ) 13 | 14 | // Process 处理扫描到的电影文件 15 | func Process(mf *media_file.MediaFile) error { 16 | movie, err := parseMoviesFile(mf) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | if movie == nil { 22 | return errors.New("movie file empty") 23 | } 24 | 25 | utils.Logger.DebugF("receive movies task: %v", movie) 26 | 27 | movie.checkCacheDir() 28 | detail, err := movie.getMovieDetail() 29 | if err != nil { 30 | return err 31 | } 32 | if detail == nil { 33 | return errors.New("get movie detail empty") 34 | } 35 | 36 | if !detail.FromCache || !movie.NfoExist(config.Collector.MoviesNfoMode) { 37 | _ = movie.saveToNfo(detail, config.Collector.MoviesNfoMode) 38 | kodi.Rpc.AddRefreshTask(kodi.TaskRefreshMovie, detail.OriginalTitle) 39 | } 40 | 41 | _ = movie.downloadImage(detail) 42 | 43 | return nil 44 | } 45 | 46 | // 解析文件, 返回详情:年份、中文名称、英文名称等 47 | func parseMoviesFile(mf *media_file.MediaFile) (*Movie, error) { 48 | movieName := utils.FilterTmpSuffix(mf.Filename) 49 | if mf.IsDisc() { // 光盘类文件使用目录名刮削 50 | movieName = filepath.Base(filepath.Dir(mf.Path)) 51 | } 52 | 53 | // 过滤无用文件 54 | if movieName[0:1] == "." || lrace.InArray(config.Collector.SkipFolders, movieName) { 55 | return nil, errors.New("invalid movie name") 56 | } 57 | 58 | // 过滤可选字符 59 | movieName = utils.FilterOptionals(movieName) 60 | 61 | // 使用自定义方法切割 62 | split := utils.Split(movieName) 63 | 64 | // 文件名识别 65 | nameStart := false 66 | nameStop := false 67 | movie := &Movie{MediaFile: mf} 68 | for _, item := range split { 69 | if resolution := utils.IsResolution(item); resolution != "" { 70 | nameStop = true 71 | continue 72 | } 73 | 74 | if year := utils.IsYear(item); year > 0 { 75 | movie.Year = year 76 | nameStop = true 77 | continue 78 | } 79 | 80 | if format := utils.IsFormat(item); len(format) > 0 { 81 | nameStop = true 82 | continue 83 | } 84 | 85 | if source := utils.IsSource(item); len(source) > 0 { 86 | nameStop = true 87 | continue 88 | } 89 | 90 | if studio := utils.IsStudio(item); len(studio) > 0 { 91 | nameStop = true 92 | continue 93 | } 94 | 95 | if channel := utils.IsChannel(item); len(channel) > 0 { 96 | nameStop = true 97 | continue 98 | } 99 | 100 | if !nameStart { 101 | nameStart = true 102 | nameStop = false 103 | } 104 | 105 | if !nameStop { 106 | movie.Title += item + " " 107 | } 108 | } 109 | 110 | movie.Title, movie.AliasTitle = utils.SplitTitleAlias(movie.Title) 111 | movie.ChsTitle, movie.EngTitle = utils.SplitChsEngTitle(movie.Title) 112 | if len(movie.Title) == 0 { 113 | return nil, errors.New(fmt.Sprintf("file: %s parse title empty: %v", mf.Filename, movie)) 114 | } 115 | 116 | return movie, nil 117 | } 118 | -------------------------------------------------------------------------------- /movies/process_struct.go: -------------------------------------------------------------------------------- 1 | package movies 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/common/constants" 5 | "fengqi/kodi-metadata-tmdb-cli/media_file" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // Movie 电影详情,从名字分析 12 | // Fortress.2021.BluRay.1080p.AVC.DTS-HD.MA5.1-MTeam 13 | type Movie struct { 14 | MediaFile *media_file.MediaFile `json:"media_file"` // 媒体文件 15 | MovieId int `json:"tv_id"` // 电影id 16 | Title string `json:"title"` // 从视频提取的完整文件名 鹰眼 Hawkeye 17 | AliasTitle string `json:"alias_title"` // 别名,通常没有用 18 | ChsTitle string `json:"chs_title"` // 分离出来的中午名称 鹰眼 19 | EngTitle string `json:"eng_title"` // 分离出来的英文名称 Hawkeye 20 | Year int `json:"year"` // 年份:2020、2021 21 | //Dir string `json:"dir"` 22 | //OriginTitle string `json:"origin_title"` // 原始目录名 23 | //VideoFileName string `json:"file_name"` // 视频文件名,仅限:IsSingleFile=true 24 | //IsFile bool `json:"is_file"` // 是否是单文件,而不是目录 25 | //Suffix string `json:"suffix"` // 单文件时,文件的后缀 26 | //IsBluRay bool `json:"is_bluray"` // 蓝光目录 27 | //IsDvd bool `json:"is_dvd"` // DVD目录 28 | //IsSingleFile bool `json:"is_single_file"` // 普通的单文件视频 29 | //IdCacheFile string `json:"id_cache_file"` 30 | //DetailCacheFile string `json:"detail_cache_file"` 31 | } 32 | 33 | // tmdb 缓存目录 34 | // TODO 统一使用一个目录而不是在每个目录下创建 35 | func (m *Movie) checkCacheDir() { 36 | dir := m.GetCacheDir() 37 | if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) { 38 | if err := os.Mkdir(dir, 0755); err != nil { 39 | utils.Logger.ErrorF("create cache: %s dir err: %v", dir, err) 40 | } 41 | } 42 | } 43 | 44 | func (m *Movie) GetCacheDir() string { 45 | base := filepath.Dir(m.MediaFile.Path) 46 | return base + "/" + constants.TmdbCacheDir 47 | } 48 | 49 | func (m *Movie) GetFullDir() string { 50 | return m.MediaFile.Path 51 | //return m.Dir + "/" + m.OriginTitle 52 | } 53 | -------------------------------------------------------------------------------- /movies/tmdb.go: -------------------------------------------------------------------------------- 1 | package movies 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fengqi/kodi-metadata-tmdb-cli/tmdb" 7 | "fengqi/kodi-metadata-tmdb-cli/utils" 8 | "github.com/fengqi/lrace" 9 | "io/ioutil" 10 | "os" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | // getMovieDetail 获取电影详情 18 | func (m *Movie) getMovieDetail() (*tmdb.MovieDetail, error) { 19 | var err error 20 | detail := &tmdb.MovieDetail{} 21 | cacheExpire := false 22 | 23 | // 缓存文件路径 24 | // todo `tmdb/movie.json` 这种格式后期删除掉 25 | oldCacheFile := m.GetCacheDir() + "/movie.json" 26 | cacheFile := m.GetCacheDir() + "/" + m.MediaFile.Filename + ".movie.json" 27 | if _, err := os.Stat(oldCacheFile); err == nil { 28 | _, _ = lrace.CopyFile(oldCacheFile, cacheFile) 29 | _ = os.Remove(oldCacheFile) 30 | } 31 | 32 | // 从缓存读取 33 | if cf, err := os.Stat(cacheFile); err == nil { 34 | utils.Logger.DebugF("get movie detail from cache: %s", cacheFile) 35 | 36 | bytes, err := os.ReadFile(cacheFile) 37 | if err != nil { 38 | utils.Logger.WarningF("read movie.json cache: %s err: %v", cacheFile, err) 39 | } 40 | 41 | err = json.Unmarshal(bytes, detail) 42 | if err != nil { 43 | utils.Logger.WarningF("parse movie: %s file err: %v", cacheFile, err) 44 | } 45 | 46 | airTime, _ := time.Parse("2006-01-02", detail.ReleaseDate) 47 | cacheExpire = utils.CacheExpire(cf.ModTime(), airTime) 48 | detail.FromCache = true 49 | } 50 | 51 | // 缓存失效,重新搜索 52 | if detail == nil || detail.Id == 0 || cacheExpire { 53 | detail.FromCache = false 54 | movieId := 0 55 | 56 | // todo 兼容 tmdb/id.txt,后期删除 57 | oldIdFile := m.GetCacheDir() + "/id.txt" 58 | idFile := m.GetCacheDir() + "/" + m.MediaFile.Filename + ".id.txt" 59 | if _, err := os.Stat(oldIdFile); err == nil { 60 | _, _ = lrace.CopyFile(oldIdFile, idFile) 61 | _ = os.Remove(oldIdFile) 62 | } 63 | 64 | if _, err = os.Stat(idFile); err == nil { 65 | bytes, err := os.ReadFile(idFile) 66 | if err != nil { 67 | utils.Logger.WarningF("id file: %s read err: %v", idFile, err) 68 | } else { 69 | movieId, _ = strconv.Atoi(strings.Trim(string(bytes), "\r\n ")) 70 | } 71 | } 72 | 73 | if movieId == 0 { 74 | SearchResults, err := tmdb.Api.SearchMovie(m.ChsTitle, m.EngTitle, m.Year) 75 | if err != nil || SearchResults == nil { 76 | utils.Logger.ErrorF("search title: %s or %s, year: %d failed", m.ChsTitle, m.EngTitle, m.Year) 77 | return detail, err 78 | } 79 | 80 | movieId = SearchResults.Id 81 | 82 | // 保存movieId 83 | err = ioutil.WriteFile(idFile, []byte(strconv.Itoa(movieId)), 0664) 84 | if err != nil { 85 | utils.Logger.ErrorF("save movieId %d to %s err: %v", movieId, idFile, err) 86 | } 87 | } 88 | 89 | // 获取详情 90 | detail, err = tmdb.Api.GetMovieDetail(movieId) 91 | if err != nil { 92 | utils.Logger.ErrorF("get movie: %d detail err: %v", movieId, err) 93 | return nil, err 94 | } 95 | 96 | // 保存到缓存 97 | m.checkCacheDir() 98 | detail.SaveToCache(cacheFile) 99 | } 100 | 101 | if detail.Id == 0 || m.Title == "" { 102 | return nil, err 103 | } 104 | 105 | return detail, err 106 | } 107 | 108 | // downloadImage 下载图片 109 | func (m *Movie) downloadImage(detail *tmdb.MovieDetail) error { 110 | utils.Logger.DebugF("download %s images", m.Title) 111 | 112 | var err error 113 | var errs error 114 | if len(detail.PosterPath) > 0 { 115 | posterFile := m.MediaFile.PathWithoutSuffix() + "-poster.jpg" 116 | err = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(detail.PosterPath), posterFile) 117 | errs = errors.Join(errs, err) 118 | } 119 | 120 | if len(detail.BackdropPath) > 0 { 121 | fanArtFile := m.MediaFile.PathWithoutSuffix() + "-fanart.jpg" 122 | err = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(detail.BackdropPath), fanArtFile) 123 | errs = errors.Join(errs, err) 124 | } 125 | 126 | if detail.Images != nil && len(detail.Images.Logos) > 0 { 127 | sort.SliceStable(detail.Images.Logos, func(i, j int) bool { 128 | return detail.Images.Logos[i].VoteAverage > detail.Images.Logos[j].VoteAverage 129 | }) 130 | 131 | image := detail.Images.Logos[0] 132 | for _, item := range detail.Images.Logos { 133 | if image.FilePath == "" && item.FilePath != "" { 134 | image = item 135 | } 136 | if item.Iso6391 == "zh" && image.Iso6391 != "zh" { // todo 语言可选 137 | image = item 138 | break 139 | } 140 | } 141 | if image.FilePath != "" { 142 | logoFile := m.MediaFile.PathWithoutSuffix() + "-clearlogo.png" 143 | _ = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(image.FilePath), logoFile) 144 | } 145 | } 146 | 147 | return errs 148 | } 149 | -------------------------------------------------------------------------------- /music_videos/collector.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/common/watcher" 5 | "fengqi/kodi-metadata-tmdb-cli/config" 6 | "fengqi/kodi-metadata-tmdb-cli/kodi" 7 | "fengqi/kodi-metadata-tmdb-cli/utils" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "time" 13 | ) 14 | 15 | type Collector struct { 16 | channel chan *MusicVideo 17 | watcher *watcher.Watcher 18 | } 19 | 20 | var collector *Collector 21 | 22 | func RunCollector() { 23 | collector = &Collector{ 24 | channel: make(chan *MusicVideo, runtime.NumCPU()), 25 | watcher: watcher.InitWatcher("music_videos"), 26 | } 27 | 28 | go collector.watcher.Run(collector.watcherCallback) 29 | go collector.runProcessor() 30 | collector.runScanner() 31 | } 32 | 33 | // 处理扫描队列 34 | func (c *Collector) runProcessor() { 35 | utils.Logger.Debug("run music videos processor") 36 | 37 | limiter := make(chan struct{}, config.Ffmpeg.MaxWorker) 38 | for { 39 | select { 40 | case video := <-c.channel: 41 | utils.Logger.DebugF("receive music video task: %v", video) 42 | 43 | limiter <- struct{}{} 44 | go func() { 45 | c.videoProcessor(video) 46 | <-limiter 47 | }() 48 | } 49 | } 50 | } 51 | 52 | // 视频文件处理 53 | func (c *Collector) videoProcessor(video *MusicVideo) { 54 | if video == nil || (video.NfoExist() && video.ThumbExist()) { 55 | return 56 | } 57 | 58 | probe, err := video.getProbe() 59 | if err != nil { 60 | utils.Logger.WarningF("parse video %s probe err: %v", video.Dir+"/"+video.OriginTitle, err) 61 | return 62 | } 63 | 64 | video.VideoStream = probe.FirstVideoStream() 65 | video.AudioStream = probe.FirstAudioStream() 66 | if video.VideoStream == nil || video.AudioStream == nil { 67 | return 68 | } 69 | 70 | err = video.drawThumb() 71 | if err != nil { 72 | utils.Logger.WarningF("draw thumb err: %v", err) 73 | return 74 | } 75 | 76 | err = video.saveToNfo() 77 | if err != nil { 78 | utils.Logger.WarningF("save to NFO err: %v", err) 79 | return 80 | } 81 | 82 | kodi.Rpc.AddScanTask(video.BaseDir) 83 | } 84 | 85 | // 运行扫描器 86 | func (c *Collector) runScanner() { 87 | utils.Logger.DebugF("run music video scanner cron_seconds: %d", config.Collector.CronSeconds) 88 | 89 | task := func() { 90 | for _, item := range config.Collector.MusicVideosDir { 91 | c.watcher.Add(item) 92 | 93 | videos, err := c.scanDir(item) 94 | if len(videos) == 0 || err != nil { 95 | continue 96 | } 97 | 98 | // 刮削信息缓存目录 99 | cacheDir := item + "/tmdb" 100 | if _, err := os.Stat(cacheDir); err != nil && os.IsNotExist(err) { 101 | err := os.Mkdir(cacheDir, 0755) 102 | if err != nil { 103 | utils.Logger.ErrorF("create probe cache: %s dir err: %v", cacheDir, err) 104 | continue 105 | } 106 | } 107 | 108 | for _, video := range videos { 109 | c.channel <- video 110 | } 111 | } 112 | } 113 | 114 | task() 115 | ticker := time.NewTicker(time.Second * time.Duration(config.Collector.CronSeconds)) 116 | for range ticker.C { 117 | task() 118 | utils.Logger.Debug("run music video scanner finished") 119 | } 120 | } 121 | 122 | func (c *Collector) scanDir(dir string) ([]*MusicVideo, error) { 123 | videos := make([]*MusicVideo, 0) 124 | dirInfo, err := ioutil.ReadDir(dir) 125 | if err != nil { 126 | utils.Logger.WarningF("scanDir %s err: %v", dir, err) 127 | return nil, err 128 | } 129 | 130 | for _, file := range dirInfo { 131 | if file.IsDir() { 132 | if c.skipFolders(dir, file.Name()) { 133 | utils.Logger.DebugF("passed in skip folders: %s", file.Name()) 134 | continue 135 | } 136 | 137 | c.watcher.Add(dir + "/" + file.Name()) 138 | 139 | subVideos, err := c.scanDir(dir + "/" + file.Name()) 140 | if err != nil { 141 | continue 142 | } 143 | 144 | if len(subVideos) > 0 { 145 | videos = append(videos, subVideos...) 146 | } 147 | 148 | continue 149 | } 150 | 151 | video := c.parseVideoFile(dir, file) 152 | if video != nil { 153 | videos = append(videos, video) 154 | } 155 | } 156 | 157 | return videos, err 158 | } 159 | 160 | func (c *Collector) skipFolders(path, filename string) bool { 161 | base := filepath.Base(path) 162 | for _, item := range config.Collector.SkipFolders { 163 | if item == base || item == filename { 164 | return true 165 | } 166 | } 167 | return false 168 | } 169 | -------------------------------------------------------------------------------- /music_videos/nfo.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/ffmpeg" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "github.com/fengqi/lrace" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | func (m *MusicVideo) saveToNfo() error { 13 | if m.NfoExist() { 14 | return nil 15 | } 16 | 17 | nfo := m.getNfoFile() 18 | utils.Logger.InfoF("save %s to %s", m.OriginTitle, nfo) 19 | 20 | fileInfo := &FileInfo{ 21 | StreamDetails: StreamDetails{ 22 | Video: []Video{ 23 | { 24 | Codec: m.VideoStream.CodecName, 25 | Aspect: m.VideoStream.DisplayAspectRatio, 26 | Width: m.VideoStream.Width, 27 | Height: m.VideoStream.Height, 28 | DurationInSeconds: m.VideoStream.Duration, 29 | StereoMode: "progressive", 30 | }, 31 | }, 32 | Audio: []Audio{ 33 | { 34 | Language: "zho", 35 | Codec: m.VideoStream.CodecName, 36 | Channels: m.VideoStream.Channels, 37 | }, 38 | }, 39 | }, 40 | } 41 | 42 | top := MusicVideoNfo{ 43 | Title: m.Title, 44 | DateAdded: m.DateAdded, 45 | FileInfo: fileInfo, 46 | Thumb: []Thumb{ 47 | { 48 | Aspect: "thumb", 49 | Preview: m.Title + "-thumb.jpg", 50 | }, 51 | }, 52 | Poster: m.Title + "-thumb.jpg", 53 | } 54 | 55 | return utils.SaveNfo(nfo, top) 56 | } 57 | 58 | // 缩略图提取 59 | // TODO 截取开始位置可配置 60 | func (m *MusicVideo) drawThumb() error { 61 | if m.ThumbExist() { 62 | return nil 63 | } 64 | 65 | // 如果有视频文件同名后缀的图片,尝试直接使用 66 | filename := m.getFullPath() 67 | thumb := m.getNfoThumb() 68 | for _, i := range ThumbImagesFormat { 69 | check := m.Dir + "/" + m.Title + "." + i 70 | if lrace.FileExist(check) { 71 | n, err := lrace.CopyFile(check, thumb) 72 | if n > 0 && err == nil { 73 | return nil 74 | } 75 | } 76 | } 77 | 78 | // 对于大文件,尝试偏移30秒,防止读到的是黑屏白屏或者logo 79 | ss := "00:00:00" 80 | second, _ := strconv.ParseFloat(m.VideoStream.Duration, 10) 81 | if m.VideoStream != nil && second > 30 { 82 | ss = "00:00:30" 83 | } 84 | 85 | base := filepath.Base(filename) 86 | if (len(base) > 2 && base[0:2] == "03") || (len(base) > 5 && strings.ToLower(base[0:5]) == "heyzo") { 87 | ss = "00:01:10" 88 | } 89 | 90 | utils.Logger.InfoF("draw thumb start: %s, %s to %s", ss, m.OriginTitle, thumb) 91 | return ffmpeg.Frame(filename, thumb, "-ss", ss) 92 | } 93 | -------------------------------------------------------------------------------- /music_videos/nfo_struct.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import "encoding/xml" 4 | 5 | var ThumbImagesFormat = []string{ 6 | "jpg", 7 | "jpeg", 8 | "gif", 9 | "png", 10 | "bmp", 11 | } 12 | 13 | type MusicVideoNfo struct { 14 | XMLName xml.Name `xml:"musicvideo"` 15 | Title string `xml:"title"` 16 | UserRating float32 `xml:"userrating"` 17 | Album string `xml:"-"` 18 | Plot string `xml:"-"` 19 | RunTime int `xml:"-"` 20 | Thumb []Thumb `xml:"thumb"` 21 | Poster string `xml:"poster"` 22 | PlayCount int `xml:"playcount"` 23 | LastPlayed string `xml:"-"` 24 | Genre []string `xml:"genre"` 25 | Tag []string `xml:"tag"` 26 | Director []string `xml:"director"` 27 | Year int `xml:"-"` 28 | Studio []string `xml:"studio"` 29 | FileInfo *FileInfo `xml:"fileinfo"` 30 | Actor []Actor `xml:"actor"` 31 | Artist string `xml:"-"` 32 | DateAdded string `xml:"dateadded"` 33 | } 34 | 35 | type Thumb struct { 36 | Aspect string `xml:"aspect,attr"` 37 | Preview string `xml:"preview,attr"` 38 | } 39 | 40 | type FileInfo struct { 41 | StreamDetails StreamDetails `xml:"streamdetails"` 42 | } 43 | 44 | type StreamDetails struct { 45 | Video []Video `xml:"video"` 46 | Audio []Audio `xml:"audio"` 47 | } 48 | 49 | type Video struct { 50 | Codec string `xml:"codec"` 51 | Aspect string `xml:"aspect"` 52 | Width int `xml:"width"` 53 | Height int `xml:"height"` 54 | DurationInSeconds string `xml:"durationinseconds"` 55 | StereoMode string `xml:"-"` 56 | } 57 | 58 | type Audio struct { 59 | Codec string `xml:"codec"` 60 | Language string `xml:"language"` 61 | Channels int `xml:"channels"` 62 | } 63 | 64 | type Actor struct { 65 | Name string `xml:"name"` 66 | Role string `xml:"role"` 67 | Order int `xml:"order"` 68 | SortOrder int `xml:"sortorder"` 69 | Thumb string `xml:"thumb"` 70 | } 71 | -------------------------------------------------------------------------------- /music_videos/parser.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "io/fs" 7 | "strings" 8 | ) 9 | 10 | func (c *Collector) parseVideoFile(dir string, file fs.FileInfo) *MusicVideo { 11 | ext := utils.IsVideo(file.Name()) 12 | if ext == "" { 13 | utils.Logger.DebugF("not a video file: %s", file.Name()) 14 | return nil 15 | } 16 | 17 | baseDir := "" 18 | for _, base := range config.Collector.MusicVideosDir { 19 | if dir == baseDir || strings.Contains(dir, base) { 20 | baseDir = base 21 | break 22 | } 23 | } 24 | 25 | return &MusicVideo{ 26 | Dir: dir, 27 | BaseDir: baseDir, 28 | OriginTitle: file.Name(), 29 | Title: strings.Replace(file.Name(), "."+ext, "", 1), 30 | DateAdded: file.ModTime().Format("2006-01-02 15:04:05"), 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /music_videos/video.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/json" 6 | "fengqi/kodi-metadata-tmdb-cli/ffmpeg" 7 | "fengqi/kodi-metadata-tmdb-cli/utils" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | ) 13 | 14 | func (m *MusicVideo) getFullPath() string { 15 | return m.Dir + "/" + m.OriginTitle 16 | } 17 | 18 | func (m *MusicVideo) getNfoThumb() string { 19 | return m.Dir + "/" + m.Title + "-thumb.jpg" 20 | } 21 | 22 | func (m *MusicVideo) getNfoFile() string { 23 | return m.Dir + "/" + m.Title + ".nfo" 24 | } 25 | 26 | func (m *MusicVideo) NfoExist() bool { 27 | nfo := m.getNfoFile() 28 | 29 | if info, err := os.Stat(nfo); err == nil && info.Size() > 0 { 30 | return true 31 | } 32 | 33 | return false 34 | } 35 | 36 | func (m *MusicVideo) ThumbExist() bool { 37 | thumb := m.getNfoThumb() 38 | if info, err := os.Stat(thumb); err == nil && info.Size() > 0 { 39 | return true 40 | } 41 | 42 | return false 43 | } 44 | 45 | func (m *MusicVideo) getProbe() (*ffmpeg.ProbeData, error) { 46 | // 读取缓存 47 | var probe = new(ffmpeg.ProbeData) 48 | 49 | fileMd5 := m.GetNameMd5() 50 | cacheFile := m.BaseDir + "/tmdb/" + fileMd5 + ".json" 51 | if _, err := os.Stat(cacheFile); err == nil { 52 | utils.Logger.DebugF("get video probe from cache: %s", cacheFile) 53 | if bytes, err := ioutil.ReadFile(cacheFile); err == nil { 54 | if err = json.Unmarshal(bytes, probe); err == nil { 55 | return probe, nil 56 | } 57 | } 58 | } 59 | 60 | // 保存缓存 61 | probe, err := ffmpeg.Probe(m.Dir + "/" + m.OriginTitle) 62 | if err == nil { 63 | utils.Logger.DebugF("save video probe to cache: %s", cacheFile) 64 | f, err := os.OpenFile(cacheFile, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 65 | if err == nil { 66 | bytes, _ := json.MarshalIndent(probe, "", " ") 67 | _, err = f.Write(bytes) 68 | _ = f.Close() 69 | } 70 | } 71 | 72 | return probe, err 73 | } 74 | 75 | func (m *MusicVideo) GetNameMd5() string { 76 | h := md5.New() 77 | _, _ = io.WriteString(h, m.getFullPath()) 78 | sum := fmt.Sprintf("%x", h.Sum(nil)) 79 | return sum 80 | } 81 | -------------------------------------------------------------------------------- /music_videos/video_struct.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/ffmpeg" 5 | ) 6 | 7 | type MusicVideo struct { 8 | Dir string 9 | BaseDir string 10 | Title string 11 | OriginTitle string 12 | DateAdded string 13 | VideoStream *ffmpeg.Stream 14 | AudioStream *ffmpeg.Stream 15 | } 16 | -------------------------------------------------------------------------------- /music_videos/watcher.go: -------------------------------------------------------------------------------- 1 | package music_videos 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/utils" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func (c *Collector) watcherCallback(filename string, fileInfo os.FileInfo) { 10 | // 新增目录 11 | if fileInfo.IsDir() { 12 | c.watcher.Add(filename) 13 | 14 | videos, err := c.scanDir(filename) 15 | if err != nil || len(videos) == 0 { 16 | utils.Logger.WarningF("new dir %s scan err: %v or no videos", filename, err) 17 | return 18 | } 19 | 20 | for _, video := range videos { 21 | c.channel <- video 22 | } 23 | 24 | return 25 | } 26 | 27 | // 单个文件 28 | if utils.IsVideo(filename) != "" { 29 | video := c.parseVideoFile(filepath.Dir(filename), fileInfo) 30 | c.channel <- video 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shows/nfo.go: -------------------------------------------------------------------------------- 1 | package shows 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/tmdb" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "github.com/fengqi/lrace" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | func (s *Show) SaveTvNfo(detail *tmdb.TvDetail) error { 13 | tvNfo := s.TvRoot + "/tvshow.nfo" 14 | if detail.FromCache && lrace.FileExist(tvNfo) { 15 | return nil 16 | } 17 | 18 | utils.Logger.InfoF("save tvshow.nfo to: %s", tvNfo) 19 | 20 | genre := make([]string, 0) 21 | for _, item := range detail.Genres { 22 | genre = append(genre, item.Name) 23 | } 24 | 25 | studio := make([]string, 0) 26 | for _, item := range detail.Networks { 27 | studio = append(studio, item.Name) 28 | } 29 | 30 | rating := make([]Rating, 1) 31 | rating[0] = Rating{ 32 | Name: "tmdb", 33 | Max: 10, 34 | Value: detail.VoteAverage, 35 | Votes: detail.VoteCount, 36 | } 37 | 38 | actor := make([]Actor, 0) 39 | if detail.AggregateCredits != nil { 40 | for _, item := range detail.AggregateCredits.Cast { 41 | if item.ProfilePath == "" { 42 | continue 43 | } 44 | 45 | actor = append(actor, Actor{ 46 | Name: item.Name, 47 | Role: item.Roles[0].Character, 48 | Order: item.Order, 49 | Thumb: tmdb.Api.GetImageW500(item.ProfilePath), 50 | }) 51 | } 52 | } 53 | 54 | episodeCount := 0 55 | namedSeason := make([]NamedSeason, 0) 56 | for _, item := range detail.Seasons { 57 | //if !s.IsCollection && item.SeasonNumber != s.Season { 58 | // continue 59 | //} 60 | namedSeason = append(namedSeason, NamedSeason{ 61 | Number: item.SeasonNumber, 62 | Value: item.Name, 63 | }) 64 | episodeCount = item.EpisodeCount 65 | } 66 | 67 | mpaa := "NR" 68 | contentRating := strings.ToUpper(config.Tmdb.Rating) 69 | if detail.ContentRatings != nil && len(detail.ContentRatings.Results) > 0 { 70 | mpaa = detail.ContentRatings.Results[0].Rating 71 | for _, item := range detail.ContentRatings.Results { 72 | if strings.ToUpper(item.ISO31661) == contentRating { 73 | mpaa = item.Rating 74 | break 75 | } 76 | } 77 | } 78 | 79 | var fanArt *FanArt 80 | if detail.BackdropPath != "" { 81 | fanArt = &FanArt{ 82 | Thumb: []ShowThumb{ 83 | { 84 | Preview: tmdb.Api.GetImageW500(detail.BackdropPath), 85 | }, 86 | }, 87 | } 88 | } 89 | 90 | top := &TvShowNfo{ 91 | Title: detail.Name, 92 | OriginalTitle: detail.OriginalName, 93 | ShowTitle: detail.Name, 94 | SortTitle: detail.Name, 95 | Plot: detail.Overview, 96 | UniqueId: UniqueId{ 97 | Type: "tmdb", 98 | Default: true, 99 | Value: strconv.Itoa(detail.Id), 100 | }, 101 | Id: detail.Id, 102 | Premiered: detail.FirstAirDate, 103 | Ratings: Ratings{Rating: rating}, 104 | MPaa: mpaa, 105 | Status: detail.Status, 106 | Genre: genre, 107 | Studio: studio, 108 | Season: s.Season, 109 | Episode: episodeCount, 110 | UserRating: detail.VoteAverage, 111 | Actor: actor, 112 | NamedSeason: namedSeason, 113 | FanArt: fanArt, 114 | } 115 | 116 | // 使用分组信息 117 | if s.GroupId != "" && detail.TvEpisodeGroupDetail != nil { 118 | top.Season = detail.TvEpisodeGroupDetail.GroupCount 119 | top.Episode = detail.TvEpisodeGroupDetail.EpisodeCount 120 | 121 | namedSeason = make([]NamedSeason, 0) 122 | for _, item := range detail.TvEpisodeGroupDetail.Groups { 123 | namedSeason = append(namedSeason, NamedSeason{ 124 | Number: item.Order, 125 | Value: item.Name, 126 | }) 127 | } 128 | top.NamedSeason = namedSeason 129 | } 130 | 131 | return utils.SaveNfo(tvNfo, top) 132 | } 133 | 134 | // SaveEpisodeNfo 保存每集的信息到独立的NFO文件 135 | func (s *Show) SaveEpisodeNfo(episode *tmdb.TvEpisodeDetail) error { 136 | episodeNfo := strings.Replace(s.MediaFile.Path, s.MediaFile.Suffix, ".nfo", 1) 137 | if episode.FromCache && lrace.FileExist(episodeNfo) { 138 | return nil 139 | } 140 | 141 | utils.Logger.InfoF("save episode nfo to: %s", episodeNfo) 142 | 143 | actor := make([]Actor, 0) 144 | for _, item := range episode.GuestStars { 145 | actor = append(actor, Actor{ 146 | Name: item.Name, 147 | Role: item.Character, 148 | Order: item.Order, 149 | Thumb: tmdb.Api.GetImageW500(item.ProfilePath), 150 | SortOrder: item.Order, 151 | }) 152 | } 153 | 154 | // 评分 155 | rating := make([]Rating, 1) 156 | rating[0] = Rating{ 157 | Name: "tmdb", 158 | Max: 10, 159 | Value: episode.VoteAverage, 160 | Votes: episode.VoteCount, 161 | } 162 | 163 | top := &TvEpisodeNfo{ 164 | Title: episode.Name, 165 | ShowTitle: episode.Name, 166 | OriginalTitle: episode.Name, 167 | Plot: episode.Overview, 168 | UniqueId: UniqueId{ 169 | Type: strconv.Itoa(episode.Id), 170 | Default: true, 171 | }, 172 | Premiered: episode.AirDate, 173 | Season: episode.SeasonNumber, 174 | Episode: episode.EpisodeNumber, 175 | DisplaySeason: episode.SeasonNumber, 176 | DisplayEpisode: episode.EpisodeNumber, 177 | UserRating: episode.VoteAverage, 178 | TmdbId: "tmdb" + strconv.Itoa(episode.Id), 179 | Runtime: 6, 180 | Actor: actor, 181 | Thumb: Thumb{ 182 | Aspect: "thumb", 183 | Preview: tmdb.Api.GetImageOriginal(episode.StillPath), 184 | }, 185 | Ratings: rating, 186 | Aired: episode.AirDate, 187 | } 188 | 189 | return utils.SaveNfo(episodeNfo, top) 190 | } 191 | -------------------------------------------------------------------------------- /shows/nfo_struct.go: -------------------------------------------------------------------------------- 1 | package shows 2 | 3 | import "encoding/xml" 4 | 5 | // TvShowNfo tvshow.nfo 6 | // 7 | // https://kodi.wiki/view/NFO_files/TV_shows 8 | // NFO files for TV Shows are a little bit more complex as they require the following NFO files: 9 | // 10 | // One nfo file for the TV Show. This file holds the overall TV show information 11 | // One nfo file for each Episode. This file holds information specific to that episode 12 | // For one TV Show with 10 episodes, 11 nfo files are required. 13 | type TvShowNfo struct { 14 | XMLName xml.Name `xml:"tvshow"` 15 | Title string `xml:"title"` 16 | OriginalTitle string `xml:"originaltitle"` 17 | ShowTitle string `xml:"showtitle"` // Not in common use, but some skins may display an alternate title 18 | SortTitle string `xml:"sorttitle"` 19 | Ratings Ratings `xml:"ratings"` 20 | UserRating float32 `xml:"userrating"` 21 | Top250 string `xml:"-"` 22 | Season int `xml:"season"` 23 | Episode int `xml:"episode"` 24 | DisplayEpisode int `xml:"-"` 25 | DisplaySeason int `xml:"-"` 26 | Outline string `xml:"-"` 27 | Plot string `xml:"plot"` 28 | Tagline string `xml:"-"` 29 | Runtime int `xml:"-"` 30 | Thumb []Thumb `xml:"-"` 31 | FanArt *FanArt `xml:"fanart"` 32 | MPaa string `xml:"mpaa"` 33 | PlayCount int `xml:"-"` 34 | LastPlayed string `xml:"-"` 35 | EpisodeGuide EpisodeGuide `xml:"-"` 36 | Id int `xml:"id"` 37 | UniqueId UniqueId `xml:"uniqueid"` 38 | Genre []string `xml:"genre"` 39 | Tag []string `xml:"tag"` 40 | Premiered string `xml:"premiered"` 41 | Year string `xml:"-"` 42 | Status string `xml:"status"` 43 | Aired string `xml:"-"` 44 | Studio []string `xml:"studio"` 45 | Trailer string `xml:"trailer"` 46 | Actor []Actor `xml:"actor"` 47 | NamedSeason []NamedSeason `xml:"namedseason"` 48 | Resume Resume `xml:"-"` 49 | DateAdded int `xml:"-"` 50 | } 51 | 52 | type TvEpisodeNfo struct { 53 | XMLName xml.Name `xml:"episodedetails"` 54 | Title string `xml:"title"` 55 | OriginalTitle string `xml:"originaltitle"` 56 | ShowTitle string `xml:"showtitle"` 57 | Ratings []Rating `xml:"ratings"` 58 | UserRating float32 `xml:"userrating"` 59 | Top250 string `xml:"top250"` 60 | Season int `xml:"season"` 61 | Episode int `xml:"episode"` 62 | DisplayEpisode int `xml:"displayepisode"` 63 | DisplaySeason int `xml:"displayseason"` 64 | Outline string `xml:"outline"` 65 | Plot string `xml:"plot"` 66 | Tagline string `xml:"-"` 67 | Runtime int `xml:"runtime"` 68 | Thumb Thumb `xml:"thumb"` 69 | 70 | UniqueId UniqueId `xml:"uniqueid"` 71 | Year string `xml:"year"` 72 | TmdbId string `xml:"tmdbid"` 73 | 74 | MPaa string `xml:"-"` 75 | Premiered string `xml:"premiered"` 76 | Actor []Actor `xml:"actor"` 77 | Status string `xml:"-"` 78 | Aired string `xml:"aired"` 79 | Genre []string `xml:"genre"` 80 | Studio []string `xml:"studio"` 81 | 82 | FileInfo FileInfo `xml:"fileinfo"` 83 | } 84 | 85 | type Ratings struct { 86 | Rating []Rating `xml:"rating"` 87 | } 88 | 89 | type Rating struct { 90 | Name string `xml:"name,attr"` 91 | Max int `xml:"max,attr"` 92 | Value float32 `xml:"value"` 93 | Votes int `xml:"votes"` 94 | } 95 | 96 | type FileInfo struct { 97 | StreamDetails StreamDetails `xml:"streamdetails"` 98 | } 99 | 100 | type StreamDetails struct { 101 | Video []Video `xml:"video"` 102 | Audio []Audio `xml:"audio"` 103 | Subtitle []Subtitle `xml:"subtitle"` 104 | } 105 | 106 | type Video struct { 107 | Codec string `xml:"codec"` 108 | Aspect string `xml:"aspect"` 109 | Width int `xml:"width"` 110 | Height int `xml:"height"` 111 | DurationInSeconds int `xml:"durationinseconds"` 112 | StereoMode int `xml:"stereomode"` 113 | } 114 | 115 | type Audio struct { 116 | Codec string `xml:"codec"` 117 | Language string `xml:"language"` 118 | Channels int `xml:"channels"` 119 | } 120 | 121 | type Subtitle struct { 122 | Codec string `xml:"codec"` 123 | Micodec string `xml:"micodec"` 124 | Language string `xml:"language"` 125 | ScanType string `xml:"scantype"` 126 | Default bool `xml:"default"` 127 | Forced bool `xml:"forced"` 128 | } 129 | 130 | type Thumb struct { 131 | Aspect string `xml:"aspect,attr"` 132 | Preview string `xml:"preview,attr"` 133 | } 134 | 135 | type UniqueId struct { 136 | XMLName xml.Name `xml:"uniqueid"` 137 | Type string `xml:"type,attr"` 138 | Default bool `xml:"default,attr"` 139 | Value string `xml:",chardata"` 140 | } 141 | 142 | type Actor struct { 143 | Name string `xml:"name"` 144 | Role string `xml:"role"` 145 | Order int `xml:"order"` 146 | SortOrder int `xml:"sortorder"` 147 | Thumb string `xml:"thumb"` 148 | } 149 | 150 | type FanArt struct { 151 | XMLName xml.Name `xml:"fanart"` 152 | Thumb []ShowThumb `xml:"thumb"` 153 | } 154 | 155 | type ShowThumb struct { 156 | Preview string `xml:"preview,attr"` 157 | } 158 | 159 | type EpisodeGuide struct { 160 | Url Url `xml:"url"` 161 | } 162 | 163 | type Url struct { 164 | Cache string `xml:"cache"` 165 | } 166 | 167 | type NamedSeason struct { 168 | Number int `xml:"number,attr"` 169 | Value string `xml:",chardata"` 170 | } 171 | 172 | type Resume struct { 173 | Position string `xml:"position"` 174 | Total int `xml:"total"` 175 | } 176 | -------------------------------------------------------------------------------- /shows/process.go: -------------------------------------------------------------------------------- 1 | package shows 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/kodi" 6 | "fengqi/kodi-metadata-tmdb-cli/media_file" 7 | "fengqi/kodi-metadata-tmdb-cli/utils" 8 | "github.com/fengqi/lrace" 9 | "log" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | // Process 处理扫描到的电视剧文件 15 | func Process(mf *media_file.MediaFile) error { 16 | show := &Show{MediaFile: mf} 17 | 18 | ParseShowFile(show, show.MediaFile.Path) 19 | 20 | show.checkTvCacheDir() 21 | detail, err := show.getTvDetail() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | show.SaveTvNfo(detail) 27 | show.downloadTvImage(detail) 28 | 29 | show.checkCacheDir() 30 | episodeDetail, err := show.getEpisodeDetail() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | show.SaveEpisodeNfo(episodeDetail) 36 | show.downloadEpisodeImage(episodeDetail) 37 | 38 | if !detail.FromCache { 39 | kodi.Rpc.AddRefreshTask(kodi.TaskRefreshTVShow, detail.OriginalName) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func ParseShowFile(show *Show, parse string) error { 46 | // 递归到根目录 47 | for _, showsDir := range config.Collector.ShowsDir { 48 | if strings.TrimRight(showsDir, "/") == parse { 49 | if show.Episode > 0 && show.Season == 0 { 50 | show.Season = 1 51 | } 52 | 53 | if show.SeasonRoot == "" { 54 | show.SeasonRoot = show.TvRoot 55 | } 56 | 57 | // 读特殊指定的值 58 | show.checkCacheDir() 59 | show.checkTvCacheDir() 60 | show.ReadSeason() 61 | show.ReadTvId() 62 | show.ReadGroupId() 63 | //show.ReadPart() 64 | 65 | return nil 66 | } 67 | } 68 | 69 | filename := filepath.Base(parse) 70 | 71 | // 过滤可选字符 72 | filename = utils.FilterOptionals(filename) 73 | 74 | // 过滤和替换中文 75 | filename = utils.ReplaceChsNumber(filename) 76 | 77 | // 过滤掉或替换歧义的内容 78 | filename = utils.SeasonCorrecting(filename) 79 | filename = utils.EpisodeCorrecting(filename) 80 | 81 | // 使用自定义方法切割 82 | split := utils.Split(filename) 83 | 84 | nameStart := false 85 | nameStop := false 86 | 87 | if show.Title != "" { 88 | nameStop = true 89 | } 90 | 91 | if lrace.IsDir(parse) { 92 | if source, season := utils.IsSeason(filename); len(season) > 0 && source == filename { 93 | split = split[0:0] 94 | show.Season = utils.StrToInt(season) 95 | show.SeasonRoot = parse 96 | } 97 | } 98 | 99 | for _, item := range split { 100 | if !nameStart && !nameStop { 101 | nameStart = true 102 | nameStop = false 103 | } 104 | 105 | if format := utils.IsFormat(item); len(format) > 0 { 106 | if show.Format == "" { 107 | show.Format = format 108 | } 109 | nameStop = true 110 | continue 111 | } 112 | 113 | if source := utils.IsSource(item); len(source) > 0 { 114 | if show.Source == "" { 115 | show.Source = source 116 | } 117 | nameStop = true 118 | continue 119 | } 120 | 121 | if studio := utils.IsStudio(item); len(studio) > 0 { 122 | show.Studio = lrace.Ternary(show.Studio == "", studio, show.Studio) 123 | nameStop = true 124 | continue 125 | } 126 | 127 | if channel := utils.IsChannel(item); len(channel) > 0 { 128 | show.Channel = lrace.Ternary(show.Channel == "", channel, show.Channel) 129 | nameStop = true 130 | continue 131 | } 132 | 133 | if coding := utils.IsVideoCoding(item); len(coding) > 0 { 134 | if show.VideoCoding == "" { 135 | show.VideoCoding = coding 136 | } 137 | nameStop = true 138 | continue 139 | } 140 | 141 | if coding := utils.IsAudioCoding(item); len(coding) > 0 { 142 | if show.AudioCoding == "" { 143 | show.AudioCoding = coding 144 | } 145 | nameStop = true 146 | continue 147 | } 148 | 149 | if crew := utils.IsCrew(item); len(crew) > 0 { 150 | show.Crew = lrace.Ternary(show.Crew == "", crew, show.Crew) 151 | nameStop = true 152 | continue 153 | } 154 | 155 | if dm := utils.IsDynamicRange(item); len(dm) > 0 { 156 | show.DynamicRange = lrace.Ternary(show.DynamicRange == "", dm, show.DynamicRange) 157 | nameStop = true 158 | continue 159 | } 160 | 161 | if year := utils.IsYear(item); year > 0 { 162 | if show.Year == 0 { 163 | show.Year = year 164 | } 165 | nameStop = true 166 | continue 167 | } 168 | 169 | if s, e := utils.MatchEpisode(item + show.MediaFile.Suffix); s > 0 && e > 0 { 170 | if show.Season == 0 { 171 | show.Season = s 172 | } 173 | if show.Episode == 0 { 174 | show.Episode = e 175 | } 176 | nameStop = true 177 | continue 178 | } 179 | 180 | if source, season := utils.IsSeason(item); len(season) > 0 { 181 | if show.Season == 0 { 182 | show.Season = utils.StrToInt(season) 183 | } 184 | if source == filename { // 目录是季,如第x季、s02 185 | show.SeasonRoot = parse 186 | break 187 | } 188 | nameStop = true 189 | continue 190 | } 191 | 192 | if source, episode := utils.IsEpisode(item + show.MediaFile.Suffix); len(episode) > 0 { 193 | if show.Episode == 0 { 194 | show.Episode = utils.StrToInt(episode) 195 | } 196 | if show.Episode > 100 { 197 | log.Println("what episode 50?") 198 | } 199 | if source == filename { // 文件名是集,如第x集、e02 200 | break 201 | } 202 | nameStop = true 203 | continue 204 | } 205 | 206 | if nameStart && !nameStop { 207 | show.Title += item + " " 208 | } 209 | } 210 | 211 | // 文件名清理 212 | show.Title = strings.TrimSpace(show.Title) 213 | show.Title, show.AliasTitle = utils.SplitTitleAlias(show.Title) 214 | show.ChsTitle, show.EngTitle = utils.SplitChsEngTitle(show.Title) 215 | 216 | show.TvRoot = filepath.Dir(parse) 217 | if lrace.InArray(config.Collector.ShowsDir, show.TvRoot) { 218 | show.TvRoot = parse 219 | } 220 | 221 | return ParseShowFile(show, filepath.Dir(parse)) 222 | } 223 | -------------------------------------------------------------------------------- /shows/process_struct.go: -------------------------------------------------------------------------------- 1 | package shows 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/common/constants" 5 | "fengqi/kodi-metadata-tmdb-cli/media_file" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // Show 电视剧 14 | type Show struct { 15 | MediaFile *media_file.MediaFile `json:"media_file"` // 媒体文件 16 | TvRoot string `json:"tv_root"` // 电视剧跟目录 17 | SeasonRoot string `json:"season_root"` // 季目录 18 | TvId int `json:"tv_id"` // TMDb tv id 19 | GroupId string `json:"group_id"` // TMDB Episode Group 20 | Season int `json:"season"` // 第几季 ,电影类 -1 21 | Episode int `json:"episode"` // 第几集,电影类 -1 22 | Title string `json:"title"` // 从视频提取的文件名 鹰眼 Hawkeye 23 | AliasTitle string `json:"alias_title"` // 别名,通常没有用 24 | ChsTitle string `json:"chs_title"` // 分离出来的中文名称 鹰眼 25 | EngTitle string `json:"eng_title"` // 分离出来的英文名称 Hawkeye 26 | Year int `json:"year"` // 年份:2020、2021 27 | Format string `json:"format"` 28 | VideoCoding string `json:"video_coding"` 29 | AudioCoding string `json:"audio_coding"` 30 | Source string `json:"source"` 31 | Studio string `json:"studio"` 32 | Channel string `json:"channel"` 33 | Crew string `json:"crew"` 34 | DynamicRange string `json:"dynamic_range"` 35 | } 36 | 37 | func (s *Show) checkCacheDir() { 38 | dir := s.GetCacheDir() 39 | if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) { 40 | if err := os.Mkdir(dir, 0755); err != nil { 41 | utils.Logger.ErrorF("create cache: %s dir err: %v", dir, err) 42 | } 43 | } 44 | } 45 | 46 | func (s *Show) checkTvCacheDir() { 47 | dir := s.GetTvCacheDir() 48 | if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) { 49 | if err := os.Mkdir(dir, 0755); err != nil { 50 | utils.Logger.ErrorF("create cache: %s dir err: %v", dir, err) 51 | } 52 | } 53 | } 54 | 55 | func (s *Show) GetTvCacheDir() string { 56 | return s.TvRoot + "/" + constants.TmdbCacheDir 57 | } 58 | 59 | func (s *Show) GetCacheDir() string { 60 | base := filepath.Dir(s.MediaFile.Path) 61 | return base + "/" + constants.TmdbCacheDir 62 | } 63 | 64 | func (s *Show) GetFullDir() string { 65 | return s.MediaFile.Path 66 | } 67 | 68 | func (s *Show) ReadSeason() { 69 | seasonFile := s.GetCacheDir() + "/season.txt" 70 | if _, err := os.Stat(seasonFile); err == nil { 71 | bytes, err := os.ReadFile(seasonFile) 72 | if err == nil { 73 | s.Season, _ = strconv.Atoi(strings.Trim(string(bytes), "\r\n ")) 74 | } else { 75 | utils.Logger.WarningF("read season specially file: %s err: %v", seasonFile, err) 76 | } 77 | } 78 | } 79 | 80 | // ReadTvId 从文件读取tvId 81 | func (s *Show) ReadTvId() { 82 | idFile := s.TvRoot + "/tmdb/id.txt" 83 | if _, err := os.Stat(idFile); err == nil { 84 | bytes, err := os.ReadFile(idFile) 85 | if err == nil { 86 | s.TvId, _ = strconv.Atoi(strings.Trim(string(bytes), "\r\n ")) 87 | } else { 88 | utils.Logger.WarningF("read tv id specially file: %s err: %v", idFile, err) 89 | } 90 | } 91 | } 92 | 93 | // CacheTvId 缓存tvId到文件 94 | func (s *Show) CacheTvId() { 95 | idFile := s.TvRoot + "/tmdb/id.txt" 96 | err := os.WriteFile(idFile, []byte(strconv.Itoa(s.TvId)), 0664) 97 | if err != nil { 98 | utils.Logger.ErrorF("save tvId %d to %s err: %v", s.TvId, idFile, err) 99 | } 100 | } 101 | 102 | // ReadGroupId 从文件读取剧集分组 103 | func (s *Show) ReadGroupId() { 104 | groupFile := s.SeasonRoot + "/tmdb/group.txt" 105 | if _, err := os.Stat(groupFile); err == nil { 106 | bytes, err := os.ReadFile(groupFile) 107 | if err == nil { 108 | s.GroupId = strings.Trim(string(bytes), "\r\n ") 109 | } else { 110 | utils.Logger.WarningF("read group id specially file: %s err: %v", groupFile, err) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /shows/process_test.go: -------------------------------------------------------------------------------- 1 | package shows 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fengqi/kodi-metadata-tmdb-cli/media_file" 6 | "github.com/agiledragon/gomonkey/v2" 7 | "github.com/stretchr/testify/assert" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | func TestParseShowFile(t *testing.T) { 13 | patches := gomonkey.NewPatches() 14 | defer patches.Reset() 15 | patches.ApplyGlobalVar(&config.Collector, &config.CollectorConfig{ShowsDir: []string{"/data/tmp/shows"}}) 16 | patches.ApplyFuncReturn(os.Mkdir, func(...any) error { return nil }) 17 | patches.ApplyPrivateMethod(&Show{}, "checkCacheDir", func() {}) 18 | patches.ApplyPrivateMethod(&Show{}, "checkTvCacheDir", func() {}) 19 | 20 | tests := []struct { 21 | name string 22 | mediaFile *media_file.MediaFile 23 | show *Show 24 | want *Show 25 | }{ 26 | { 27 | name: "S01E01.mp4", 28 | mediaFile: &media_file.MediaFile{ 29 | Path: "/data/tmp/shows/庆余年/庆余年S02/S02E03.mp4", 30 | Filename: "S02E03.mp4", 31 | Suffix: ".mp4", 32 | MediaType: media_file.VIDEO, 33 | VideoType: media_file.TvShows, 34 | }, 35 | want: &Show{ 36 | Title: "庆余年", 37 | Season: 2, 38 | Episode: 3, 39 | }, 40 | }, 41 | { 42 | name: "Season Dir", 43 | mediaFile: &media_file.MediaFile{ 44 | Path: "/data/tmp/shows/庆余年/庆余年/S02/E03.mp4", 45 | Filename: "E03.mp4", 46 | Suffix: ".mp4", 47 | MediaType: media_file.VIDEO, 48 | VideoType: media_file.TvShows, 49 | }, 50 | want: &Show{ 51 | Title: "庆余年", 52 | Season: 2, 53 | Episode: 3, 54 | }, 55 | }, 56 | { 57 | name: "Season Dir", 58 | mediaFile: &media_file.MediaFile{ 59 | Path: "/data/tmp/shows/9-1-1.Lone.Star.S01.1080p.DSNP.WEB-DL.DDP5.1.H264-HHWEB/9-1-1.Lone.Star.S01E08.Monster.Inside.1080p.DSNP.WEB-DL.DDP5.1.H264-HHWEB.mkv", 60 | Filename: "9-1-1.Lone.Star.S01E08.Monster.Inside.1080p.DSNP.WEB-DL.DDP5.1.H264-HHWEB.mkv", 61 | Suffix: ".mkv", 62 | MediaType: media_file.VIDEO, 63 | VideoType: media_file.TvShows, 64 | }, 65 | want: &Show{ 66 | Title: "9 1 1 Lone Star", 67 | Season: 1, 68 | Episode: 8, 69 | }, 70 | }, 71 | { 72 | name: "Season Dir", 73 | mediaFile: &media_file.MediaFile{ 74 | Path: "/data/tmp/shows/Gannibal.2022.Disney+.WEB-DL.4K.HEVC.HDR.DDP-HDCTV/Gannibal.E01.2022.Disney+.WEB-DL.4K.HEVC.HDR.DDP-HDCTV.mkv", 75 | Filename: "Gannibal.E01.2022.Disney+.WEB-DL.4K.HEVC.HDR.DDP-HDCTV.mkv", 76 | Suffix: ".mkv", 77 | MediaType: media_file.VIDEO, 78 | VideoType: media_file.TvShows, 79 | }, 80 | want: &Show{ 81 | Title: "Gannibal", 82 | Season: 1, 83 | Episode: 1, 84 | }, 85 | }, 86 | } 87 | for _, tt := range tests { 88 | t.Run(tt.name, func(t *testing.T) { 89 | show := &Show{MediaFile: tt.mediaFile} 90 | ParseShowFile(show, tt.mediaFile.Path) 91 | 92 | assert.Equal(t, tt.want.Title, show.Title) 93 | assert.Equal(t, tt.want.Season, show.Season) 94 | assert.Equal(t, tt.want.Episode, show.Episode) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /shows/tmdb.go: -------------------------------------------------------------------------------- 1 | package shows 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fengqi/kodi-metadata-tmdb-cli/common/memcache" 7 | "fengqi/kodi-metadata-tmdb-cli/tmdb" 8 | "fengqi/kodi-metadata-tmdb-cli/utils" 9 | "fmt" 10 | "github.com/fengqi/lrace" 11 | "io/ioutil" 12 | "os" 13 | "sort" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | func (s *Show) getTvDetail() (*tmdb.TvDetail, error) { 19 | var err error 20 | var detail = new(tmdb.TvDetail) 21 | 22 | cacheKey := fmt.Sprintf("show:%d", s.TvId) 23 | if val, ok := memcache.Cache.Get(cacheKey); ok { 24 | if detail, ok = val.(*tmdb.TvDetail); ok { 25 | utils.Logger.DebugF("get tv detail from memcache: %d", s.TvId) 26 | return detail, nil 27 | } 28 | } 29 | 30 | // 从缓存读取 31 | tvCacheFile := s.GetTvCacheDir() + "/tv.json" 32 | cacheExpire := false 33 | if cf, err := os.Stat(tvCacheFile); err == nil { 34 | utils.Logger.DebugF("get tv detail from cache: %s", tvCacheFile) 35 | 36 | bytes, err := os.ReadFile(tvCacheFile) 37 | if err != nil { 38 | utils.Logger.WarningF("read tv.json cache: %s err: %v", tvCacheFile, err) 39 | goto search 40 | } 41 | 42 | err = json.Unmarshal(bytes, detail) 43 | if err != nil { 44 | utils.Logger.WarningF("parse tv file: %s err: %v", tvCacheFile, err) 45 | _ = os.Remove(tvCacheFile) 46 | goto search 47 | } 48 | 49 | airTime, _ := time.Parse("2006-01-02", detail.LastAirDate) 50 | cacheExpire = utils.CacheExpire(cf.ModTime(), airTime) 51 | detail.FromCache = true 52 | s.TvId = detail.Id 53 | } 54 | 55 | search: 56 | // 缓存失效,重新搜索 57 | if detail == nil || detail.Id == 0 || cacheExpire { 58 | detail.FromCache = false 59 | if s.TvId == 0 { 60 | SearchResults, err := tmdb.Api.SearchShows(s.ChsTitle, s.EngTitle, s.Year) 61 | if err != nil || SearchResults == nil { 62 | utils.Logger.ErrorF("search title: %s year: %d failed", s.Title, s.Year) 63 | return detail, err 64 | } 65 | 66 | s.TvId = SearchResults.Id 67 | } 68 | 69 | // 获取详情 70 | detail, err = tmdb.Api.GetTvDetail(s.TvId) 71 | if err != nil || detail == nil || detail.Id == 0 || detail.Name == "" { 72 | utils.Logger.ErrorF("get tv: %d detail err: %v", s.TvId, err) 73 | return nil, err 74 | } 75 | 76 | // 保存到缓存 77 | detail.SaveToCache(tvCacheFile) 78 | } 79 | 80 | // 剧集分组:不同的季版本 81 | if s.GroupId != "" { 82 | groupDetail, err := s.getTvEpisodeGroupDetail() 83 | if err == nil { 84 | detail.TvEpisodeGroupDetail = groupDetail 85 | } 86 | } 87 | 88 | if s.TvId > 0 { 89 | s.CacheTvId() 90 | cacheKey = fmt.Sprintf("show:%d", s.TvId) 91 | memcache.Cache.SetDefault(cacheKey, detail) 92 | } 93 | 94 | return detail, nil 95 | } 96 | 97 | func (s *Show) getEpisodeDetail() (*tmdb.TvEpisodeDetail, error) { 98 | var err error 99 | var detail = new(tmdb.TvEpisodeDetail) 100 | 101 | cacheFile := fmt.Sprintf("%s/tmdb/s%02de%02d.json", lrace.Ternary(s.SeasonRoot != "", s.SeasonRoot, s.TvRoot), s.Season, s.Episode) 102 | cacheExpire := false 103 | if cf, err := os.Stat(cacheFile); err == nil { 104 | utils.Logger.DebugF("get episode from cache: %s", cacheFile) 105 | 106 | bytes, err := os.ReadFile(cacheFile) 107 | if err != nil { 108 | utils.Logger.WarningF("read episode cache: %s err: %v", cacheFile, err) 109 | } 110 | 111 | err = json.Unmarshal(bytes, &detail) 112 | if err != nil { 113 | utils.Logger.WarningF("parse episode cache: %s err: %v", cacheFile, err) 114 | } 115 | 116 | airTime, _ := time.Parse("2006-01-02", detail.AirDate) 117 | cacheExpire = utils.CacheExpire(cf.ModTime(), airTime) 118 | detail.FromCache = true 119 | } 120 | 121 | // 请求tmdb 122 | if detail == nil || detail.Id == 0 || cacheExpire { 123 | detail.FromCache = false 124 | detail, err = tmdb.Api.GetTvEpisodeDetail(s.TvId, s.Season, s.Episode) 125 | if err != nil { 126 | return nil, errors.Join(errors.New("get tv episode error"), err) 127 | } 128 | 129 | if detail == nil || detail.Id == 0 { 130 | return nil, errors.New(fmt.Sprintf("get episode from tmdb: %d season: %d episode: %d failed", s.TvId, s.Season, s.Episode)) 131 | } 132 | 133 | // 保存到缓存 134 | detail.SaveToCache(cacheFile) 135 | } 136 | 137 | if detail.Id == 0 || detail.Name == "" { 138 | return nil, err 139 | } 140 | 141 | return detail, err 142 | } 143 | 144 | func (s *Show) getTvEpisodeGroupDetail() (*tmdb.TvEpisodeGroupDetail, error) { 145 | if s.GroupId == "" { 146 | return nil, nil 147 | } 148 | 149 | var err error 150 | var detail = new(tmdb.TvEpisodeGroupDetail) 151 | 152 | // 从缓存读取 153 | cacheFile := s.SeasonRoot + "/tmdb/group.json" 154 | cacheExpire := false 155 | if cf, err := os.Stat(cacheFile); err == nil { 156 | utils.Logger.DebugF("get tv episode group detail from cache: %s", cacheFile) 157 | 158 | bytes, err := ioutil.ReadFile(cacheFile) 159 | if err != nil { 160 | utils.Logger.WarningF("read group.json cache: %s err: %v", cacheFile, err) 161 | } 162 | 163 | err = json.Unmarshal(bytes, detail) 164 | if err != nil { 165 | utils.Logger.WarningF("parse group.json file: %s err: %v", cacheFile, err) 166 | } 167 | 168 | airTime, _ := time.Parse("2006-01-02", detail.Groups[len(detail.Groups)-1].Episodes[0].AirDate) 169 | cacheExpire = utils.CacheExpire(cf.ModTime(), airTime) 170 | detail.FromCache = true 171 | } 172 | 173 | // 缓存失效,重新搜索 174 | if detail == nil || detail.Id == "" || cacheExpire { 175 | detail.FromCache = false 176 | detail, err = tmdb.Api.GetTvEpisodeGroupDetail(s.GroupId) 177 | if err != nil { 178 | utils.Logger.ErrorF("get tv episode group: %s detail err: %v", s.GroupId, err) 179 | return nil, err 180 | } 181 | 182 | // 保存到缓存 183 | detail.SaveToCache(cacheFile) 184 | } 185 | 186 | return detail, nil 187 | } 188 | 189 | // 下载电视剧的相关图片 190 | // TODO 下载失败后,没有重复以及很长一段时间都不会再触发下载 191 | func (s *Show) downloadTvImage(detail *tmdb.TvDetail) { 192 | if len(detail.PosterPath) > 0 { 193 | _ = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(detail.PosterPath), s.TvRoot+"/poster.jpg") 194 | } 195 | 196 | if len(detail.BackdropPath) > 0 { 197 | _ = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(detail.BackdropPath), s.TvRoot+"/fanart.jpg") 198 | } 199 | 200 | // TODO group的信息里可能 season poster不全 201 | if len(detail.Seasons) > 0 { 202 | for _, item := range detail.Seasons { 203 | if /*!s.IsCollection &&*/ item.SeasonNumber != s.Season || item.PosterPath == "" { 204 | continue 205 | } 206 | seasonPoster := fmt.Sprintf("season%02d-poster.jpg", item.SeasonNumber) 207 | _ = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(item.PosterPath), s.TvRoot+"/"+seasonPoster) 208 | } 209 | } 210 | 211 | if detail.Images != nil && len(detail.Images.Logos) > 0 { 212 | sort.SliceStable(detail.Images.Logos, func(i, j int) bool { 213 | return detail.Images.Logos[i].VoteAverage > detail.Images.Logos[j].VoteAverage 214 | }) 215 | image := detail.Images.Logos[0] 216 | for _, item := range detail.Images.Logos { 217 | if image.FilePath == "" && item.FilePath != "" { 218 | image = item 219 | } 220 | if item.Iso6391 == "zh" && image.Iso6391 != "zh" { 221 | image = item 222 | break 223 | } 224 | } 225 | if image.FilePath != "" { 226 | logoFile := s.TvRoot + "/clearlogo.png" 227 | _ = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(image.FilePath), logoFile) 228 | } 229 | } 230 | } 231 | 232 | // 下载剧集的相关图片 233 | func (s *Show) downloadEpisodeImage(d *tmdb.TvEpisodeDetail) { 234 | file := strings.Replace(s.MediaFile.Path, s.MediaFile.Suffix, "-thumb.jpg", 1) 235 | if len(d.StillPath) > 0 { 236 | _ = tmdb.DownloadFile(tmdb.Api.GetImageOriginal(d.StillPath), file) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /tmdb/movies.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | // GetMovieDetail 获取电影详情 11 | func (t *tmdb) GetMovieDetail(id int) (*MovieDetail, error) { 12 | utils.Logger.DebugF("get movie detail from tmdb: %d", id) 13 | 14 | api := fmt.Sprintf(ApiMovieDetail, id) 15 | req := map[string]string{ 16 | "append_to_response": "credits,releases,images", 17 | "include_image_language": "zh,en,null", 18 | } 19 | 20 | body, err := t.request(api, req) 21 | if err != nil { 22 | utils.Logger.ErrorF("get movie detail err: %d %v", id, err) 23 | return nil, err 24 | } 25 | 26 | detail := &MovieDetail{} 27 | err = json.Unmarshal(body, detail) 28 | if err != nil { 29 | utils.Logger.ErrorF("parse movie detail err: %d %v", id, err) 30 | return nil, err 31 | } 32 | 33 | return detail, err 34 | } 35 | 36 | // SaveToCache 保存剧集详情到文件 37 | func (d *MovieDetail) SaveToCache(file string) { 38 | if d.Id == 0 || d.Title == "" { 39 | return 40 | } 41 | 42 | utils.Logger.InfoF("save movie detail to: %s", file) 43 | 44 | f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 45 | if err != nil { 46 | utils.Logger.ErrorF("save movie to cache, open_file err: %v", err) 47 | return 48 | } 49 | defer func(f *os.File) { 50 | err := f.Close() 51 | if err != nil { 52 | utils.Logger.WarningF("save movie to cache, close file err: %v", err) 53 | } 54 | }(f) 55 | 56 | bytes, err := json.MarshalIndent(d, "", " ") 57 | if err != nil { 58 | utils.Logger.ErrorF("save movie to cache, marshal struct err: %v", err) 59 | return 60 | } 61 | 62 | _, err = f.Write(bytes) 63 | } 64 | -------------------------------------------------------------------------------- /tmdb/movies_search.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "fmt" 8 | "strconv" 9 | ) 10 | 11 | func (t *tmdb) SearchMovie(chsTitle, engTitle string, year int) (*SearchMoviesResults, error) { 12 | utils.Logger.InfoF("search: %s or %s %d from tmdb", chsTitle, engTitle, year) 13 | 14 | strYear := strconv.Itoa(year) 15 | searchComb := make([]map[string]string, 0) 16 | 17 | if chsTitle != "" { 18 | // chs + year 19 | if year > 0 { 20 | searchComb = append(searchComb, map[string]string{ 21 | "query": chsTitle, 22 | "page": "1", 23 | "include_adult": "true", 24 | //"region": "US", 25 | "year": strYear, 26 | "primary_release_year": strYear, 27 | }) 28 | } 29 | // chs 30 | searchComb = append(searchComb, map[string]string{ 31 | "query": chsTitle, 32 | "page": "1", 33 | "include_adult": "true", 34 | //"region": "US", 35 | }) 36 | } 37 | 38 | if engTitle != "" { 39 | // eng + year 40 | if year > 0 { 41 | searchComb = append(searchComb, map[string]string{ 42 | "query": engTitle, 43 | "page": "1", 44 | "include_adult": "true", 45 | //"region": "US", 46 | "year": strYear, 47 | "primary_release_year": strYear, 48 | }) 49 | } 50 | // eng 51 | searchComb = append(searchComb, map[string]string{ 52 | "query": engTitle, 53 | "page": "1", 54 | "include_adult": "true", 55 | //"region": "US", 56 | }) 57 | } 58 | 59 | if len(searchComb) == 0 { 60 | return nil, errors.New("title empty") 61 | } 62 | 63 | moviesResp := &SearchMoviesResponse{} 64 | for _, req := range searchComb { 65 | body, err := t.request(ApiSearchMovie, req) 66 | if err != nil { 67 | utils.Logger.ErrorF("read tmdb response err: %v", err) 68 | continue 69 | } 70 | 71 | err = json.Unmarshal(body, moviesResp) 72 | if err != nil { 73 | utils.Logger.ErrorF("parse tmdb response err: %v", err) 74 | continue 75 | } 76 | 77 | if len(moviesResp.Results) > 0 { 78 | utils.Logger.InfoF("search movies: %s %d result count: %d, use: %v", chsTitle, year, len(moviesResp.Results), moviesResp.Results[0]) 79 | return moviesResp.Results[0], nil 80 | } 81 | } 82 | 83 | return nil, errors.New(fmt.Sprintf("search movie %s-%s-%d not found", chsTitle, engTitle, year)) 84 | } 85 | -------------------------------------------------------------------------------- /tmdb/movirs_struct.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | // MovieDetail 电影详情 4 | type MovieDetail struct { 5 | Adult bool `json:"adult"` 6 | BackdropPath string `json:"backdrop_path"` 7 | BelongsToCollection BelongsToCollection `json:"belongs_to_collection"` 8 | Budget int `json:"budget"` 9 | Genres []Genre `json:"genres"` 10 | Homepage string `json:"homepage"` 11 | Id int `json:"id"` 12 | ImdbId string `json:"imdb_id"` 13 | OriginalLanguage string `json:"original_language"` 14 | OriginalTitle string `json:"original_title"` 15 | Overview string `json:"overview"` 16 | Popularity float32 `json:"popularity"` 17 | PosterPath string `json:"poster_path"` 18 | ProductionCompanies []ProductionCompany `json:"production_companies"` 19 | ProductionCountries []ProductionCountry `json:"production_countries"` 20 | ReleaseDate string `json:"release_date"` 21 | Revenue int64 `json:"revenue"` 22 | Runtime int `json:"runtime"` 23 | SpokenLanguages []SpokenLanguage `json:"spoken_languages"` 24 | Status string `json:"status"` 25 | Tagline string `json:"tagline"` 26 | Title string `json:"title"` 27 | Video bool `json:"video"` 28 | VoteAverage float32 `json:"vote_average"` 29 | VoteCount int `json:"vote_count"` 30 | Credits *Credit `json:"credits"` 31 | FromCache bool `json:"from_cache"` 32 | Releases MovieRelease `json:"releases"` 33 | Images *MovieImages `json:"images"` 34 | } 35 | 36 | type BelongsToCollection struct { 37 | Id int `json:"id"` 38 | Name string `json:"name"` 39 | PosterPath string `json:"poster_path"` 40 | BackdropPath string `json:"backdrop_path"` 41 | } 42 | 43 | type Credit struct { 44 | Id string `json:"id"` 45 | Cast []MovieCast `json:"cast"` 46 | Crew []MovieCrew `json:"crew"` 47 | } 48 | 49 | type MovieCast struct { 50 | Adult bool `json:"adult"` 51 | Gender int `json:"gender"` 52 | Id int `json:"id"` 53 | KnownForDepartment string `json:"known_for_department"` 54 | Name string `json:"name"` 55 | OriginalName string `json:"original_name"` 56 | Popularity float32 `json:"popularity"` 57 | ProfilePath string `json:"profile_path"` 58 | CastId int `json:"cast_id"` 59 | Character string `json:"character"` 60 | CreditId string `json:"credit_id"` 61 | Order int `json:"order"` 62 | } 63 | 64 | // MovieCrew 电影工作人员 65 | type MovieCrew struct { 66 | Adult bool `json:"adult"` 67 | Gender int `json:"gender"` 68 | Id int `json:"id"` 69 | KnownForDepartment string `json:"known_for_department"` 70 | Name string `json:"name"` 71 | OriginalName string `json:"original_name"` 72 | Popularity float32 `json:"popularity"` 73 | ProfilePath string `json:"profile_path"` 74 | CreditId string `json:"credit_id"` 75 | Department string `json:"department"` 76 | Job string `json:"job"` 77 | } 78 | 79 | // SearchMoviesResponse 搜索电影的结果 80 | type SearchMoviesResponse struct { 81 | Page int `json:"page"` 82 | TotalResults int `json:"total_results"` 83 | TotalPages int `json:"total_pages"` 84 | Results []*SearchMoviesResults `json:"results"` 85 | } 86 | 87 | // SearchMoviesResults 搜索电影的结果 88 | type SearchMoviesResults struct { 89 | PosterPath string `json:"poster_path"` 90 | Adult bool `json:"adult"` 91 | Overview string `json:"overview"` 92 | ReleaseDate string `json:"release_date"` 93 | GenreIds []int `json:"genre_ids"` 94 | Id int `json:"id"` 95 | OriginalTitle string `json:"original_title"` 96 | OriginalLanguage string `json:"original_language"` 97 | Title string `json:"title"` 98 | BackdropPath string `json:"backdrop_path"` 99 | Popularity float32 `json:"popularity"` 100 | VoteCount int `json:"vote_count"` 101 | Video bool `json:"video"` 102 | VoteAverage float32 `json:"vote_average"` 103 | } 104 | 105 | // MovieRelease 电影各国家上映时间和分级 106 | type MovieRelease struct { 107 | Countries []ReleaseCountry `json:"countries"` 108 | } 109 | 110 | type ReleaseCountry struct { 111 | Certification string `json:"certification"` 112 | ISO31661 string `json:"iso_3166_1"` 113 | Primary bool `json:"primary"` 114 | ReleaseDate string `json:"release_date"` 115 | } 116 | 117 | type MovieImage struct { 118 | AspectRatio float32 `json:"aspect_ratio"` 119 | Height int `json:"height"` 120 | Iso6391 string `json:"iso_639_1"` 121 | FilePath string `json:"file_path"` 122 | VoteAverage float32 `json:"vote_average"` 123 | VoteCount int `json:"vote_count"` 124 | Width int `json:"width"` 125 | } 126 | 127 | type MovieImages struct { 128 | Id int `json:"id"` 129 | Logos []*MovieImage `json:"logos"` 130 | Posters []*MovieImage `json:"posters"` 131 | Backdrops []*MovieImage `json:"backdrops"` 132 | } 133 | -------------------------------------------------------------------------------- /tmdb/search_tv.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "sort" 8 | "strconv" 9 | ) 10 | 11 | type SearchTvResponse struct { 12 | Page int `json:"page"` 13 | TotalResults int `json:"total_results"` 14 | TotalPages int `json:"total_pages"` 15 | Results []*SearchResults `json:"results"` 16 | } 17 | 18 | type SearchResults struct { 19 | Id int `json:"id"` 20 | PosterPath string `json:"poster_path"` 21 | Popularity float32 `json:"popularity"` 22 | BackdropPath string `json:"backdrop_path"` 23 | VoteAverage float32 `json:"vote_average"` 24 | Overview string `json:"overview"` 25 | FirstAirDate string `json:"first_air_date"` 26 | OriginCountry []string `json:"origin_country"` 27 | GenreIds []int `json:"genre_ids"` 28 | OriginalLanguage string `json:"original_language"` 29 | VoteCount int `json:"vote_count"` 30 | Name string `json:"name"` 31 | OriginalName string `json:"original_name"` 32 | } 33 | 34 | type Response struct { 35 | Success bool `json:"success"` 36 | StatusCode int `json:"status_code"` 37 | StatusMessage string `json:"status_message"` 38 | } 39 | 40 | // SearchTvResultsSortWrapper 自定义排序 41 | type SearchTvResultsSortWrapper struct { 42 | results []*SearchResults 43 | by func(l, r *SearchResults) bool 44 | } 45 | 46 | func (rw SearchTvResultsSortWrapper) Len() int { 47 | return len(rw.results) 48 | } 49 | func (rw SearchTvResultsSortWrapper) Swap(i, j int) { 50 | rw.results[i], rw.results[j] = rw.results[j], rw.results[i] 51 | } 52 | func (rw SearchTvResultsSortWrapper) Less(i, j int) bool { 53 | return rw.by(rw.results[i], rw.results[j]) 54 | } 55 | 56 | // SortResults 按流行度排序 57 | // TODO 是否有点太粗暴了,考虑多维度:内容完整性、年份、中英文等 58 | func (d SearchTvResponse) SortResults() { 59 | sort.Sort(SearchTvResultsSortWrapper{d.Results, func(l, r *SearchResults) bool { 60 | return l.Popularity > r.Popularity 61 | }}) 62 | } 63 | 64 | // SearchShows 搜索tmdb 65 | func (t *tmdb) SearchShows(chsTitle, engTitle string, year int) (*SearchResults, error) { 66 | utils.Logger.InfoF("search: %s or %s %d from tmdb", chsTitle, engTitle, year) 67 | 68 | strYear := strconv.Itoa(year) 69 | searchComb := make([]map[string]string, 0) 70 | 71 | if chsTitle != "" { 72 | if year > 0 { 73 | searchComb = append(searchComb, map[string]string{ 74 | "query": chsTitle, 75 | "page": "1", 76 | "include_adult": "true", 77 | "year": strYear, 78 | }) 79 | } 80 | searchComb = append(searchComb, map[string]string{ 81 | "query": chsTitle, 82 | "page": "1", 83 | "include_adult": "true", 84 | }) 85 | } 86 | 87 | if engTitle != "" { 88 | if year > 0 { 89 | searchComb = append(searchComb, map[string]string{ 90 | "query": engTitle, 91 | "page": "1", 92 | "include_adult": "true", 93 | "year": strYear, 94 | }) 95 | } 96 | searchComb = append(searchComb, map[string]string{ 97 | "query": engTitle, 98 | "page": "1", 99 | "include_adult": "true", 100 | }) 101 | } 102 | 103 | if len(searchComb) == 0 { 104 | return nil, errors.New("title empty") 105 | } 106 | 107 | tvResp := &SearchTvResponse{} 108 | for _, req := range searchComb { 109 | body, err := t.request(ApiSearchTv, req) 110 | if err != nil { 111 | utils.Logger.ErrorF("read tmdb response err: %v", err) 112 | continue 113 | } 114 | 115 | err = json.Unmarshal(body, tvResp) 116 | if err != nil { 117 | utils.Logger.ErrorF("parse tmdb response err: %v", err) 118 | continue 119 | } 120 | 121 | if len(tvResp.Results) > 0 { 122 | //tvResp.SortResults() 123 | utils.Logger.DebugF("search tv: %s %d result count: %d, use: %v", chsTitle, year, len(tvResp.Results), tvResp.Results[0]) 124 | return tvResp.Results[0], nil 125 | } 126 | } 127 | 128 | return nil, errors.New("search tv not found") 129 | } 130 | -------------------------------------------------------------------------------- /tmdb/search_tv_test.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | -------------------------------------------------------------------------------- /tmdb/tmdb.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "context" 5 | "fengqi/kodi-metadata-tmdb-cli/config" 6 | "fengqi/kodi-metadata-tmdb-cli/utils" 7 | "golang.org/x/net/proxy" 8 | "io" 9 | "io/ioutil" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "time" 15 | ) 16 | 17 | var Api *tmdb 18 | var HttpClient *http.Client 19 | 20 | const ( 21 | ApiSearchTv = "/3/search/tv" 22 | ApiSearchMovie = "/3/search/movie" 23 | ApiTvDetail = "/3/tv/%d" 24 | ApiTvEpisode = "/3/tv/%d/season/%d/episode/%d" 25 | ApiTvAggregateCredits = "/3/tv/%d/aggregate_credits" 26 | ApiTvContentRatings = "/3/tv/%d/content_ratings" 27 | ApiTvEpisodeGroup = "/3/tv/episode_group/%s" 28 | ApiMovieDetail = "/3/movie/%d" 29 | ) 30 | 31 | func InitTmdb() { 32 | HttpClient = getHttpClient(config.Tmdb.Proxy) 33 | Api = &tmdb{ 34 | apiHost: config.Tmdb.ApiHost, 35 | apiKey: config.Tmdb.ApiKey, 36 | imageHost: config.Tmdb.ImageHost, 37 | language: config.Tmdb.Language, 38 | rating: config.Tmdb.Rating, 39 | } 40 | } 41 | 42 | // GetImageW500 压缩后的图片 43 | func (t *tmdb) GetImageW500(path string) string { 44 | if path == "" { 45 | return "" 46 | } 47 | return Api.imageHost + "/t/p/w500" + path 48 | } 49 | 50 | // GetImageOriginal 原始图片 51 | func (t *tmdb) GetImageOriginal(path string) string { 52 | if path == "" { 53 | return "" 54 | } 55 | return Api.imageHost + "/t/p/original" + path 56 | } 57 | 58 | func (t *tmdb) request(api string, args map[string]string) ([]byte, error) { 59 | if args == nil { 60 | args = make(map[string]string, 0) 61 | } 62 | 63 | args["api_key"] = t.apiKey 64 | args["language"] = t.language 65 | 66 | api = t.apiHost + api + "?" + utils.StringMapToQuery(args) 67 | resp, err := HttpClient.Get(api) 68 | if err != nil { 69 | utils.Logger.ErrorF("request tmdb: %s err: %v", api, err) 70 | return nil, err 71 | } 72 | 73 | defer func(Body io.ReadCloser) { 74 | err := Body.Close() 75 | if err != nil { 76 | utils.Logger.WarningF("request tmdb close body err: %v", err) 77 | } 78 | }(resp.Body) 79 | 80 | return ioutil.ReadAll(resp.Body) 81 | } 82 | 83 | // DownloadFile 下载文件, 提供网址和目的地 84 | func DownloadFile(url string, filename string) error { 85 | if info, err := os.Stat(filename); err == nil && info.Size() > 0 { 86 | return nil 87 | } 88 | 89 | utils.Logger.InfoF("download %s to %s", url, filename) 90 | 91 | resp, err := HttpClient.Get(url) 92 | if err != nil { 93 | utils.Logger.ErrorF("download: %s err: %v", url, err) 94 | return err 95 | } 96 | defer func(Body io.ReadCloser) { 97 | err := Body.Close() 98 | if err != nil { 99 | utils.Logger.WarningF("download file, close body err: %v", err) 100 | } 101 | }(resp.Body) 102 | 103 | if resp.StatusCode != 200 { 104 | utils.Logger.ErrorF("download: %s status code failed: %d", url, resp.StatusCode) 105 | return nil 106 | } 107 | 108 | f, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 109 | if err != nil { 110 | utils.Logger.ErrorF("download: %s open_file %s err: %v", url, filename, err) 111 | return err 112 | } 113 | defer func(f *os.File) { 114 | err := f.Close() 115 | if err != nil { 116 | utils.Logger.WarningF("download file, close file %s err: %v", filename, err) 117 | } 118 | }(f) 119 | 120 | _, err = io.Copy(f, resp.Body) 121 | if err != nil { 122 | utils.Logger.ErrorF("save content to image: %s err: %v", filename, err) 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | 129 | // 支持 http 和 socks5 代理 130 | func getHttpClient(proxyConnect string) *http.Client { 131 | proxyUrl, err := url.Parse(proxyConnect) 132 | if err != nil || proxyConnect == "" { 133 | return http.DefaultClient 134 | } 135 | 136 | if proxyUrl.Scheme == "http" || proxyUrl.Scheme == "https" { 137 | _ = os.Setenv("HTTP_PROXY", proxyConnect) 138 | _ = os.Setenv("HTTPS_PROXY", proxyConnect) 139 | 140 | return http.DefaultClient 141 | } 142 | 143 | if proxyUrl.Scheme == "socks5" || proxyUrl.Scheme == "socks5h" { 144 | dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) { 145 | dialer := &net.Dialer{ 146 | Timeout: 30 * time.Second, 147 | KeepAlive: 30 * time.Second, 148 | } 149 | 150 | proxyDialer, err := proxy.FromURL(proxyUrl, dialer) 151 | if err != nil { 152 | utils.Logger.WarningF("tmdb new proxy dialer err: %v\n", err) 153 | return dialer.Dial(network, addr) 154 | } 155 | 156 | return proxyDialer.Dial(network, addr) 157 | } 158 | 159 | transport := http.DefaultTransport.(*http.Transport) 160 | transport.DialContext = dialContext 161 | return &http.Client{ 162 | Transport: transport, 163 | } 164 | } 165 | 166 | return http.DefaultClient 167 | } 168 | -------------------------------------------------------------------------------- /tmdb/tmdb_struct.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | type tmdb struct { 4 | apiHost string 5 | apiKey string 6 | imageHost string 7 | language string 8 | rating string 9 | } 10 | -------------------------------------------------------------------------------- /tmdb/tmdb_test.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | -------------------------------------------------------------------------------- /tmdb/tv.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | // TvDetailsRequest 11 | // Append To Response: https://developers.themoviedb.org/3/getting-started/append-to-response 12 | type TvDetailsRequest struct { 13 | ApiKey string `json:"api_key"` // api_key, required 14 | Language string `json:"language"` // ISO 639-1, optional, default en-US 15 | TvId int `json:"tv_id"` // tv id, required 16 | AppendToResponse string `json:"append_to_response"` // optional 17 | } 18 | 19 | type TvDetail struct { 20 | Id int `json:"id"` 21 | Name string `json:"name"` 22 | BackdropPath string `json:"backdrop_path"` 23 | CreatedBy []CreatedBy `json:"created_by"` 24 | EpisodeRunTime []int `json:"episode_run_time"` 25 | FirstAirDate string `json:"first_air_date"` 26 | LastAirDate string `json:"last_air_date"` 27 | Genres []Genre `json:"genres"` 28 | Homepage string `json:"homepage"` 29 | InProduction bool `json:"in_production"` 30 | Languages []string `json:"languages"` 31 | LastEpisodeToAir LastEpisodeToAir `json:"last_episode_to_air"` 32 | NextEpisodeToAir NextEpisodeToAir `json:"next_episode_to_air"` 33 | Networks []Network `json:"networks"` 34 | NumberOfEpisodes int `json:"number_of_episodes"` 35 | NumberOfSeasons int `json:"number_of_seasons"` 36 | OriginCountry []string `json:"origin_country"` 37 | OriginalLanguage string `json:"original_language"` 38 | OriginalName string `json:"original_name"` 39 | Overview string `json:"overview"` 40 | Popularity float32 `json:"popularity"` 41 | PosterPath string `json:"poster_path"` 42 | ProductionCompanies []ProductionCompany `json:"production_companies"` 43 | ProductionCountries []ProductionCountry `json:"production_countries"` 44 | Seasons []Season `json:"seasons"` 45 | SpokenLanguages []SpokenLanguage `json:"spoken_languages"` 46 | Status string `json:"status"` 47 | Tagline string `json:"tagline"` 48 | Type string `json:"type"` 49 | VoteAverage float32 `json:"vote_average"` 50 | VoteCount int `json:"vote_count"` 51 | AggregateCredits *TvAggregateCredits `json:"aggregate_credits"` 52 | ContentRatings *TvContentRatings `json:"content_ratings"` 53 | TvEpisodeGroupDetail *TvEpisodeGroupDetail `json:"tv_episode_group_detail"` 54 | Images *TvImages `json:"images"` 55 | FromCache bool `json:"from_cache"` 56 | } 57 | 58 | type TvImage struct { 59 | AspectRatio float32 `json:"aspect_ratio"` 60 | Height int `json:"height"` 61 | Iso6391 string `json:"iso_639_1"` 62 | FilePath string `json:"file_path"` 63 | VoteAverage float32 `json:"vote_average"` 64 | VoteCount int `json:"vote_count"` 65 | Width int `json:"width"` 66 | } 67 | 68 | type TvImages struct { 69 | Id int `json:"id"` 70 | Logos []*TvImage `json:"logos"` 71 | Posters []*TvImage `json:"posters"` 72 | Backdrops []*TvImage `json:"backdrops"` 73 | } 74 | 75 | type Genre struct { 76 | Id int `json:"id"` 77 | Name string `json:"name"` 78 | } 79 | 80 | type Network struct { 81 | Id int `json:"id"` 82 | Name string `json:"name"` 83 | LogoPath string `json:"logo_path"` 84 | OriginCountry string `json:"origin_country"` 85 | } 86 | 87 | type CreatedBy struct { 88 | Id int `json:"id"` 89 | CreditId string `json:"credit_id"` 90 | Name string `json:"name"` 91 | Gender int `json:"gender"` 92 | ProfilePath string `json:"profile_path"` 93 | } 94 | 95 | type LastEpisodeToAir struct { 96 | Id int `json:"id"` 97 | AirDate string `json:"air_date"` 98 | EpisodeNumber int `json:"episode_number"` 99 | Name string `json:"name"` 100 | Overview string `json:"overview"` 101 | ProductionCode string `json:"production_code"` 102 | SeasonNumber int `json:"season_number"` 103 | StillPath string `json:"still_path"` 104 | VoteAverage float32 `json:"vote_average"` 105 | VoteCount int `json:"vote_count"` 106 | } 107 | 108 | type NextEpisodeToAir struct { 109 | Id int `json:"id"` 110 | AirDate string `json:"air_date"` 111 | EpisodeNumber int `json:"episode_number"` 112 | Name string `json:"name"` 113 | Overview string `json:"overview"` 114 | ProductionCode string `json:"production_code"` 115 | SeasonNumber int `json:"season_number"` 116 | StillPath string `json:"still_path"` 117 | VoteAverage float32 `json:"vote_average"` 118 | VoteCount int `json:"vote_count"` 119 | } 120 | 121 | type ProductionCompany struct { 122 | Id int `json:"id"` 123 | LogoPath string `json:"logo_path"` 124 | Name string `json:"name"` 125 | OriginCountry string `json:"origin_country"` 126 | } 127 | 128 | type ProductionCountry struct { 129 | Iso31661 string `json:"iso_3166_1"` 130 | Name string `json:"name"` 131 | } 132 | 133 | type Season struct { 134 | Id int `json:"id"` 135 | AirDate string `json:"air_date"` 136 | EpisodeCount int `json:"episode_count"` 137 | Name string `json:"name"` 138 | Overview string `json:"overview"` 139 | PosterPath string `json:"poster_path"` 140 | SeasonNumber int `json:"season_number"` 141 | } 142 | 143 | type SpokenLanguage struct { 144 | EnglishName string `json:"english_name"` 145 | Iso6391 string `json:"iso_639_1"` 146 | Name string `json:"name"` 147 | } 148 | 149 | func (t *tmdb) GetTvDetail(id int) (*TvDetail, error) { 150 | utils.Logger.DebugF("get tv detail from tmdb: %d", id) 151 | 152 | api := fmt.Sprintf(ApiTvDetail, id) 153 | req := map[string]string{ 154 | "append_to_response": "aggregate_credits,content_ratings,images", 155 | "include_image_language": "zh,en,null", 156 | } 157 | 158 | body, err := t.request(api, req) 159 | if err != nil { 160 | utils.Logger.ErrorF("read tmdb response err: %v", err) 161 | return nil, err 162 | } 163 | 164 | tvResp := &TvDetail{} 165 | err = json.Unmarshal(body, tvResp) 166 | if err != nil { 167 | utils.Logger.ErrorF("parse tmdb response err: %v", err) 168 | return nil, err 169 | } 170 | 171 | return tvResp, err 172 | } 173 | 174 | func (r *TvDetailsRequest) ToQuery() string { 175 | return fmt.Sprintf( 176 | "api_key=%s&language=%s&append_to_response=%s", 177 | r.ApiKey, 178 | r.Language, 179 | r.AppendToResponse, 180 | ) 181 | } 182 | 183 | // SaveToCache 保存剧集详情到文件 184 | func (d *TvDetail) SaveToCache(file string) { 185 | if d.Id == 0 || d.Name == "" { 186 | return 187 | } 188 | 189 | utils.Logger.InfoF("save tv detail to: %s", file) 190 | 191 | f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 192 | if err != nil { 193 | utils.Logger.ErrorF("save tv to cache, open_file err: %v", err) 194 | return 195 | } 196 | defer func(f *os.File) { 197 | err := f.Close() 198 | if err != nil { 199 | utils.Logger.WarningF("save tv to cache, close file err: %v", err) 200 | } 201 | }(f) 202 | 203 | bytes, err := json.MarshalIndent(d, "", " ") 204 | if err != nil { 205 | utils.Logger.ErrorF("save tv to cache, marshal struct errr: %v", err) 206 | return 207 | } 208 | 209 | _, err = f.Write(bytes) 210 | } 211 | -------------------------------------------------------------------------------- /tmdb/tv_aggregate_credits.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | ) 8 | 9 | type TvAggregateCredits struct { 10 | Id string `json:"id"` 11 | Cast []TvCast `json:"cast"` 12 | Crew []TvCrew `json:"crew"` 13 | } 14 | 15 | type TvCast struct { 16 | Adult bool `json:"adult"` 17 | Gender int `json:"gender"` 18 | Id int `json:"id"` 19 | KnownForDepartment string `json:"known_for_department"` 20 | Name string `json:"name"` 21 | OriginalName string `json:"original_name"` 22 | Popularity float32 `json:"popularity"` 23 | ProfilePath string `json:"profile_path"` 24 | Roles []Role `json:"roles"` 25 | TotalEpisodeCount int `json:"total_episode_count"` 26 | Order int `json:"order"` 27 | } 28 | 29 | type Role struct { 30 | CreditId string `json:"credit_id"` 31 | Character string `json:"character"` 32 | EpisodeCount int `json:"episode_count"` 33 | } 34 | 35 | type Job struct { 36 | CreditId string `json:"credit_id"` 37 | Job string `json:"job"` 38 | EpisodeCount int `json:"episode_count"` 39 | } 40 | 41 | type TvCrew struct { 42 | Adult bool `json:"adult"` 43 | Gender int `json:"gender"` 44 | Id int `json:"id"` 45 | KnownForDepartment string `json:"known_for_department"` 46 | Name string `json:"name"` 47 | OriginalName string `json:"original_name"` 48 | Popularity float32 `json:"popularity"` 49 | ProfilePath string `json:"profile_path"` 50 | Jobs []Job `json:"jobs"` 51 | Department string `json:"department"` 52 | TotalEpisodeCount int `json:"total_episode_count"` 53 | } 54 | 55 | func (t *tmdb) GetTvAggregateCredits(tvId int) (*TvAggregateCredits, error) { 56 | utils.Logger.DebugF("get tv aggregate credits from tmdb: %d", tvId) 57 | 58 | api := fmt.Sprintf(ApiTvAggregateCredits, tvId) 59 | req := map[string]string{} 60 | 61 | body, err := t.request(api, req) 62 | if err != nil { 63 | utils.Logger.ErrorF("read tmdb response: %s err: %v", api, err) 64 | return nil, err 65 | } 66 | 67 | credits := &TvAggregateCredits{} 68 | err = json.Unmarshal(body, credits) 69 | if err != nil { 70 | utils.Logger.ErrorF("parse tmdb response: %s err: %v", api, err) 71 | return nil, err 72 | } 73 | 74 | return credits, err 75 | } 76 | -------------------------------------------------------------------------------- /tmdb/tv_content_ratings.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | ) 8 | 9 | // TV 内容分级 10 | 11 | type TvContentRatings struct { 12 | Id int `json:"id"` 13 | Results []TvContentRatingsResult `json:"results"` 14 | } 15 | 16 | type TvContentRatingsResult struct { 17 | ISO31661 string `json:"iso_3166_1"` 18 | Rating string `json:"rating"` 19 | } 20 | 21 | func (t *tmdb) GetTvContentRatings(tvId int) (*TvContentRatings, error) { 22 | utils.Logger.DebugF("get tv content ratings from tmdb: %d", tvId) 23 | 24 | api := fmt.Sprintf(ApiTvContentRatings, tvId) 25 | req := map[string]string{} 26 | 27 | body, err := t.request(api, req) 28 | if err != nil { 29 | utils.Logger.ErrorF("read tmdb response: %s err: %v", api, err) 30 | return nil, err 31 | } 32 | 33 | ratings := &TvContentRatings{} 34 | err = json.Unmarshal(body, ratings) 35 | if err != nil { 36 | utils.Logger.ErrorF("parse tmdb response: %s err: %v", api, err) 37 | return nil, err 38 | } 39 | 40 | return ratings, err 41 | } 42 | -------------------------------------------------------------------------------- /tmdb/tv_episode.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | type TvEpisodeRequest struct { 11 | ApiKey string `json:"api_key"` 12 | Language string `json:"language"` 13 | AppendToResponse string `json:"append_to_response"` 14 | } 15 | 16 | type TvEpisodeDetail struct { 17 | AirDate string `json:"air_date"` 18 | Crew []Crew `json:"crew"` 19 | GuestStars []GuestStars `json:"guest_stars"` 20 | Name string `json:"name"` 21 | Overview string `json:"overview"` 22 | Id int `json:"id"` 23 | ProductionCode string `json:"production_code"` 24 | SeasonNumber int `json:"season_number"` 25 | EpisodeNumber int `json:"episode_number"` 26 | StillPath string `json:"still_path"` 27 | VoteAverage float32 `json:"vote_average"` 28 | VoteCount int `json:"vote_count"` 29 | FromCache bool `json:"from_cache"` 30 | } 31 | 32 | type Crew struct { 33 | Id int `json:"id"` 34 | CreditId string `json:"credit_id"` 35 | Name string `json:"name"` 36 | Department string `json:"department"` 37 | Job string `json:"job"` 38 | ProfilePath string `json:"profile_path"` 39 | } 40 | 41 | type GuestStars struct { 42 | Id int `json:"id"` 43 | Name string `json:"name"` 44 | CreditId string `json:"credit_id"` 45 | Character string `json:"character"` 46 | Order int `json:"order"` 47 | ProfilePath string `json:"profile_path"` 48 | } 49 | 50 | func (t *tmdb) GetTvEpisodeDetail(tvId, season, episode int) (*TvEpisodeDetail, error) { 51 | utils.Logger.DebugF("get tv episode detail from tmdb: %d %d-%d", tvId, season, episode) 52 | 53 | if tvId <= 0 || season <= 0 || episode <= 0 { 54 | return nil, nil 55 | } 56 | 57 | api := fmt.Sprintf(ApiTvEpisode, tvId, season, episode) 58 | req := map[string]string{ 59 | "append_to_response": "", 60 | } 61 | 62 | body, err := t.request(api, req) 63 | if err != nil { 64 | utils.Logger.ErrorF("read tmdb response: %s err: %v", api, err) 65 | return nil, err 66 | } 67 | 68 | tvResp := &TvEpisodeDetail{} 69 | err = json.Unmarshal(body, tvResp) 70 | if err != nil { 71 | utils.Logger.ErrorF("parse tmdb response: %s err: %v", api, err) 72 | return nil, err 73 | } 74 | 75 | return tvResp, err 76 | } 77 | 78 | func (r *TvEpisodeRequest) ToQuery() string { 79 | return fmt.Sprintf( 80 | "api_key=%s&language=%s&append_to_response=%s", 81 | r.ApiKey, 82 | r.Language, 83 | r.AppendToResponse, 84 | ) 85 | } 86 | 87 | // SaveToCache 保存单集详情到文件 88 | func (d *TvEpisodeDetail) SaveToCache(file string) { 89 | if d.Id == 0 || d.Name == "" { 90 | return 91 | } 92 | 93 | utils.Logger.InfoF("save episode detail to: %s", file) 94 | 95 | f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 96 | if err != nil { 97 | utils.Logger.ErrorF("save to episode file: %s err: %v", file, err) 98 | return 99 | } 100 | defer func(f *os.File) { 101 | err := f.Close() 102 | if err != nil { 103 | utils.Logger.WarningF("save to episode file, close file err: %v", err) 104 | } 105 | }(f) 106 | 107 | bytes, err := json.MarshalIndent(d, "", " ") 108 | if err != nil { 109 | utils.Logger.ErrorF("save to episode, marshal err: %v", err) 110 | return 111 | } 112 | 113 | _, err = f.Write(bytes) 114 | } 115 | -------------------------------------------------------------------------------- /tmdb/tv_episode_group.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "fmt" 7 | "os" 8 | "sort" 9 | ) 10 | 11 | type TvEpisodeGroupDetail struct { 12 | Id string `json:"id"` 13 | Name string `json:"name"` 14 | Type int `json:"type"` 15 | Network Network `json:"network"` 16 | GroupCount int `json:"group_count"` 17 | EpisodeCount int `json:"episode_count"` 18 | Description string `json:"description"` 19 | Groups []TvEpisodeGroup `json:"groups"` 20 | FromCache bool `json:"from_cache"` 21 | } 22 | 23 | type TvEpisodeGroup struct { 24 | Id string `json:"id"` 25 | Name string `json:"name"` 26 | Order int `json:"order"` 27 | Episodes []TvEpisodeGroupEpisode `json:"episodes"` 28 | Locked bool `json:"locked"` 29 | } 30 | 31 | type TvEpisodeGroupEpisode struct { 32 | AirDate string `json:"air_date"` 33 | EpisodeNumber int `json:"episode_number"` 34 | Id int `json:"id"` 35 | Name string `json:"name"` 36 | Overview string `json:"overview"` 37 | ProductionCode string `json:"production_code"` 38 | SeasonNumber int `json:"season_number"` 39 | ShowId int `json:"show_id"` 40 | StillPath string `json:"still_path"` 41 | VoteAverage float32 `json:"vote_average"` 42 | VoteCount int `json:"vote_count"` 43 | Order int `json:"order"` 44 | } 45 | 46 | type TvEpisodeGroupEpisodeWrapper struct { 47 | episodes []TvEpisodeGroupEpisode 48 | by func(l, r *TvEpisodeGroupEpisode) bool 49 | } 50 | 51 | func (ew TvEpisodeGroupEpisodeWrapper) Len() int { 52 | return len(ew.episodes) 53 | } 54 | func (ew TvEpisodeGroupEpisodeWrapper) Swap(i, j int) { 55 | ew.episodes[i], ew.episodes[j] = ew.episodes[j], ew.episodes[i] 56 | } 57 | func (ew TvEpisodeGroupEpisodeWrapper) Less(i, j int) bool { 58 | return ew.by(&ew.episodes[i], &ew.episodes[j]) 59 | } 60 | 61 | func (d TvEpisodeGroup) SortEpisode() { 62 | sort.Sort(TvEpisodeGroupEpisodeWrapper{d.Episodes, func(l, r *TvEpisodeGroupEpisode) bool { 63 | return l.Order < r.Order 64 | }}) 65 | } 66 | 67 | func (t *tmdb) GetTvEpisodeGroupDetail(groupId string) (*TvEpisodeGroupDetail, error) { 68 | utils.Logger.DebugF("get tv episode group detail from tmdb: %s", groupId) 69 | 70 | if groupId == "" { 71 | return nil, nil 72 | } 73 | 74 | api := fmt.Sprintf(ApiTvEpisodeGroup, groupId) 75 | req := map[string]string{ 76 | //"append_to_response": "", 77 | } 78 | 79 | body, err := t.request(api, req) 80 | if err != nil { 81 | utils.Logger.ErrorF("read tmdb response: %s err: %v", api, err) 82 | return nil, err 83 | } 84 | 85 | tvResp := &TvEpisodeGroupDetail{} 86 | err = json.Unmarshal(body, tvResp) 87 | if err != nil { 88 | utils.Logger.ErrorF("parse tmdb response: %s err: %v", api, err) 89 | return nil, err 90 | } 91 | 92 | return tvResp, err 93 | } 94 | 95 | func (d TvEpisodeGroupDetail) SaveToCache(file string) { 96 | if d.Id == "" || d.Name == "" { 97 | return 98 | } 99 | 100 | utils.Logger.InfoF("save tv episode group detail to: %s", file) 101 | 102 | f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 103 | if err != nil { 104 | utils.Logger.ErrorF("save tv episode group detail to cache, open_file err: %v", err) 105 | return 106 | } 107 | defer func(f *os.File) { 108 | err := f.Close() 109 | if err != nil { 110 | utils.Logger.WarningF("save tv episode group detail to cache, close file err: %v", err) 111 | } 112 | }(f) 113 | 114 | bytes, err := json.MarshalIndent(d, "", " ") 115 | if err != nil { 116 | utils.Logger.ErrorF("save tv episode group detail to cache, marshal struct err: %v", err) 117 | return 118 | } 119 | 120 | _, err = f.Write(bytes) 121 | } 122 | -------------------------------------------------------------------------------- /tmdb/tv_nfo.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/xml" 5 | "fengqi/kodi-metadata-tmdb-cli/utils" 6 | "strconv" 7 | ) 8 | 9 | type TvShowNfo struct { 10 | XMLName xml.Name `xml:"tvshow"` 11 | Title string `xml:"title"` 12 | OriginalTitle string `xml:"originaltitle"` 13 | ShowTitle string `xml:"showtitle"` // Not in common use, but some skins may display an alternate title 14 | SortTitle string `xml:"sorttitle"` 15 | Ratings Ratings `xml:"ratings"` 16 | UserRating float32 `xml:"userrating"` 17 | Top250 string `xml:"-"` 18 | Season int `xml:"season"` 19 | Episode int `xml:"episode"` 20 | DisplayEpisode int `xml:"-"` 21 | DisplaySeason int `xml:"-"` 22 | Outline string `xml:"-"` 23 | Plot string `xml:"plot"` 24 | Tagline string `xml:"-"` 25 | Runtime int `xml:"-"` 26 | Thumb []Thumb `xml:"-"` 27 | FanArt FanArt `xml:"fanart"` 28 | MPaa string `xml:"mpaa"` 29 | PlayCount int `xml:"-"` 30 | LastPlayed string `xml:"-"` 31 | EpisodeGuide EpisodeGuide `xml:"-"` 32 | Id int `xml:"id"` 33 | UniqueId UniqueId `xml:"uniqueid"` 34 | Genre []string `xml:"genre"` 35 | Tag []string `xml:"tag"` 36 | Year string `xml:"year"` 37 | Status string `xml:"status"` 38 | Aired string `xml:"-"` 39 | Studio []string `xml:"studio"` 40 | Trailer string `xml:"trailer"` 41 | Actor []Actor `xml:"actor"` 42 | NamedSeason []NamedSeason `xml:"namedseason"` 43 | Resume Resume `xml:"-"` 44 | DateAdded int `xml:"-"` 45 | } 46 | 47 | type Ratings struct { 48 | Rating []Rating `xml:"rating"` 49 | } 50 | 51 | type Rating struct { 52 | Name string `xml:"name,attr"` 53 | Max int `xml:"max,attr"` 54 | Value float32 `xml:"value"` 55 | Votes int `xml:"votes"` 56 | } 57 | 58 | type Thumb struct { 59 | Aspect string `xml:"aspect,attr"` 60 | Preview string `xml:"preview,attr"` 61 | Season int `xml:"season"` 62 | } 63 | 64 | type UniqueId struct { 65 | XMLName xml.Name `xml:"uniqueid"` 66 | Type string `xml:"type,attr"` 67 | Default bool `xml:"default,attr"` 68 | } 69 | 70 | type Actor struct { 71 | Name string `xml:"name"` 72 | Role string `xml:"role"` 73 | Order int `xml:"order"` 74 | SortOrder int `xml:"sortorder"` 75 | Thumb string `xml:"thumb"` 76 | } 77 | 78 | type FanArt struct { 79 | XMLName xml.Name `xml:"fanart"` 80 | Thumb []ShowThumb `xml:"thumb"` 81 | } 82 | 83 | type ShowThumb struct { 84 | Preview string `xml:"preview,attr"` 85 | } 86 | 87 | type EpisodeGuide struct { 88 | Url Url `xml:"url"` 89 | } 90 | 91 | type Url struct { 92 | Cache string `xml:"cache"` 93 | } 94 | 95 | type NamedSeason struct { 96 | Number string `xml:"number"` 97 | } 98 | 99 | type Resume struct { 100 | Position string `xml:"position"` 101 | Total int `xml:"total"` 102 | } 103 | 104 | // SaveToNfo 保存剧集汇总信息到nfo文件 105 | func (d *TvDetail) SaveToNfo(nfo string) error { 106 | utils.Logger.InfoF("save tvshow.nfo to %s", nfo) 107 | 108 | genre := make([]string, 0) 109 | for _, item := range d.Genres { 110 | genre = append(genre, item.Name) 111 | } 112 | 113 | studio := make([]string, 0) 114 | for _, item := range d.Networks { 115 | studio = append(studio, item.Name) 116 | } 117 | 118 | rating := make([]Rating, 1) 119 | rating[0] = Rating{ 120 | Name: "tmdb", 121 | Max: 10, 122 | Value: d.VoteAverage, 123 | Votes: d.VoteCount, 124 | } 125 | 126 | actor := make([]Actor, 0) 127 | if d.AggregateCredits != nil { 128 | for _, item := range d.AggregateCredits.Cast { 129 | actor = append(actor, Actor{ 130 | Name: item.Name, 131 | Role: item.Roles[0].Character, 132 | Order: item.Order, 133 | Thumb: Api.GetImageOriginal(item.ProfilePath), 134 | }) 135 | } 136 | } 137 | 138 | year := "" 139 | if len(d.FirstAirDate) > 3 { 140 | year = d.FirstAirDate[0:4] 141 | } 142 | 143 | top := &TvShowNfo{ 144 | Title: d.Name, 145 | OriginalTitle: d.OriginalName, 146 | ShowTitle: d.Name, 147 | SortTitle: d.Name, 148 | Plot: d.Overview, 149 | UniqueId: UniqueId{ 150 | Type: strconv.Itoa(d.Id), 151 | Default: true, 152 | }, 153 | Id: d.Id, 154 | Year: year, 155 | Ratings: Ratings{Rating: rating}, 156 | MPaa: "TV-14", 157 | Status: d.Status, 158 | Genre: genre, 159 | Studio: studio, 160 | Season: d.NumberOfSeasons, 161 | Episode: d.NumberOfEpisodes, 162 | UserRating: d.VoteAverage, 163 | Actor: actor, 164 | FanArt: FanArt{ 165 | Thumb: []ShowThumb{ 166 | { 167 | Preview: Api.GetImageOriginal(d.BackdropPath), 168 | }, 169 | }, 170 | }, 171 | } 172 | 173 | return utils.SaveNfo(nfo, top) 174 | } 175 | -------------------------------------------------------------------------------- /tmdb/tv_test.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | -------------------------------------------------------------------------------- /utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | func StringMapToQuery(m map[string]string) string { 9 | if len(m) == 0 { 10 | return "" 11 | } 12 | 13 | s := "" 14 | for k, v := range m { 15 | s += k + "=" + url.QueryEscape(v) + "&" 16 | } 17 | 18 | return strings.TrimRight(s, "&") 19 | } 20 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fmt" 6 | "log" 7 | "os" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type logLevel int 13 | 14 | const ( 15 | DEBUG logLevel = iota 16 | INFO 17 | WARNING 18 | ERROR 19 | FATAL 20 | ) 21 | 22 | const ( 23 | LogModeStdout = 1 24 | LogModeLogfile = 2 25 | LogModeBoth = 3 26 | ) 27 | 28 | var ( 29 | Logger *logger 30 | levelMap = map[logLevel]string{ 31 | DEBUG: "debug", 32 | INFO: "info", 33 | WARNING: "warning", 34 | ERROR: "error", 35 | FATAL: "fatal", 36 | } 37 | ) 38 | 39 | type logger struct { 40 | level logLevel 41 | lock *sync.Mutex 42 | file *os.File 43 | mode int 44 | } 45 | 46 | func InitLogger() { 47 | var err error 48 | var file *os.File 49 | if config.Log.Mode != LogModeStdout { 50 | file, err = os.OpenFile(config.Log.File, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666) 51 | if err != nil { 52 | log.Fatalf("open log file:%s err: %v", config.Log.File, err) 53 | } 54 | } 55 | 56 | Logger = &logger{ 57 | level: logLevel(config.Log.Level), 58 | lock: new(sync.Mutex), 59 | file: file, 60 | mode: config.Log.Mode, 61 | } 62 | } 63 | 64 | func (l *logger) Debug(v ...interface{}) { 65 | l.print(DEBUG, v...) 66 | } 67 | 68 | func (l *logger) DebugF(format string, v ...interface{}) { 69 | l.printf(DEBUG, format, v...) 70 | } 71 | 72 | func (l *logger) Info(v ...interface{}) { 73 | l.print(INFO, v...) 74 | } 75 | 76 | func (l *logger) InfoF(format string, v ...interface{}) { 77 | l.printf(INFO, format, v...) 78 | } 79 | 80 | func (l *logger) Warning(v ...interface{}) { 81 | l.print(WARNING, v...) 82 | } 83 | 84 | func (l *logger) WarningF(format string, v ...interface{}) { 85 | l.printf(WARNING, format, v...) 86 | } 87 | 88 | func (l *logger) Error(v ...interface{}) { 89 | l.print(ERROR, v...) 90 | } 91 | 92 | func (l *logger) ErrorF(format string, v ...interface{}) { 93 | l.printf(ERROR, format, v...) 94 | } 95 | 96 | func (l *logger) Fatal(v ...interface{}) { 97 | if FATAL >= l.level { 98 | l.write(FATAL, fmt.Sprint(v...)) 99 | if l.mode != LogModeLogfile { 100 | log.Fatal(v...) 101 | } 102 | } 103 | } 104 | 105 | func (l *logger) FatalF(format string, v ...interface{}) { 106 | if FATAL >= l.level { 107 | l.write(FATAL, fmt.Sprintf(format, v...)) 108 | if l.mode != LogModeLogfile { 109 | log.Fatalf(format, v...) 110 | } 111 | } 112 | } 113 | 114 | func (l *logger) print(level logLevel, v ...interface{}) { 115 | if level >= l.level { 116 | l.write(level, fmt.Sprint(v...)) 117 | if l.mode != LogModeLogfile { 118 | log.Print(v...) 119 | } 120 | } 121 | } 122 | 123 | func (l *logger) printf(level logLevel, format string, v ...interface{}) { 124 | if level >= l.level { 125 | l.write(level, fmt.Sprintf(format, v...)) 126 | if l.mode != LogModeLogfile { 127 | log.Printf(levelMap[level]+" "+format, v...) 128 | } 129 | } 130 | } 131 | 132 | func (l *logger) write(level logLevel, str string) { 133 | if l.file == nil || l.mode == LogModeStdout { 134 | return 135 | } 136 | 137 | l.lock.Lock() 138 | defer l.lock.Unlock() 139 | 140 | // 结尾自动空格 141 | if len(str) == 0 || str[len(str)-1] != '\n' { 142 | str += "\n" 143 | } 144 | 145 | now := time.Now().Format("2006/01/02 15:04:05") 146 | levelStr, _ := levelMap[level] 147 | str = fmt.Sprintf("%s %s %s", now, levelStr, str) 148 | 149 | _, err := l.file.WriteString(str) 150 | if err != nil { 151 | log.Fatalf("write log file: %s, err: %v", l.file.Name(), err) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /utils/nfo.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/xml" 5 | "os" 6 | ) 7 | 8 | func SaveNfo(file string, v interface{}) error { 9 | if file == "" { 10 | return nil 11 | } 12 | 13 | bytes, err := xml.MarshalIndent(v, "", " ") 14 | if err != nil { 15 | Logger.WarningF("save nfo marshal err: %v", err) 16 | return err 17 | } 18 | 19 | f, err := os.OpenFile(file, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 20 | if err != nil { 21 | Logger.WarningF("save nfo open file err: %s, %v", file, err) 22 | return err 23 | } 24 | defer func(f *os.File) { 25 | err := f.Close() 26 | if err != nil { 27 | Logger.WarningF("save nfo close file err: %v", err) 28 | } 29 | }(f) 30 | 31 | _, err = f.Write([]byte(xml.Header)) 32 | if err != nil { 33 | Logger.WarningF("save nfo write err: %v", err) 34 | return err 35 | } 36 | 37 | _, err = f.Write(bytes) 38 | if err != nil { 39 | Logger.WarningF("save nfo write err: %v", err) 40 | return err 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // EndsWith 字符以xx结尾 9 | func EndsWith(str, subStr string) bool { 10 | index := strings.LastIndex(str, subStr) 11 | 12 | return index > 0 && str[index:] == subStr 13 | } 14 | 15 | func StrToInt(str string) int { 16 | if n, err := strconv.Atoi(str); err == nil { 17 | return n 18 | } 19 | return 0 20 | } 21 | -------------------------------------------------------------------------------- /utils/string_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type endsWith struct { 8 | str string 9 | subStr string 10 | with bool 11 | } 12 | 13 | func TestEndsWith(t *testing.T) { 14 | cases := []endsWith{ 15 | {"china", "na", true}, 16 | {"china", "ch", false}, 17 | {"string", "ring", true}, 18 | {"string", "g", true}, 19 | {"string", "s", false}, 20 | } 21 | for _, item := range cases { 22 | give := EndsWith(item.str, item.subStr) 23 | if give != item.with { 24 | t.Errorf("EndsWith(%s, %s) give: %v, want: %v", item.str, item.subStr, give, item.with) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /utils/time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // CacheExpire 判断缓存到期,1年以上的永久缓存,一年到半年的一礼拜,半年内的3天 8 | // TODO 可配置 9 | func CacheExpire(modTime, airTime time.Time) bool { 10 | airSub := time.Now().Sub(airTime) 11 | if airSub.Hours() >= 24*365 { 12 | return false 13 | } 14 | 15 | cacheSub := time.Now().Sub(modTime) 16 | if airSub.Hours() >= 24*180 && cacheSub.Hours() > 24*7 { 17 | return false 18 | } 19 | 20 | return cacheSub.Hours() > 24*3 21 | } 22 | -------------------------------------------------------------------------------- /utils/video.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fengqi/kodi-metadata-tmdb-cli/config" 5 | "fmt" 6 | "github.com/fengqi/lrace" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "unicode" 11 | ) 12 | 13 | var ( 14 | // todo 可配置 15 | video = []string{ 16 | "mkv", 17 | "mp4", 18 | "ts", 19 | "avi", 20 | "wmv", 21 | "m4v", 22 | "flv", 23 | "webm", 24 | "mpeg", 25 | "mpg", 26 | "3gp", 27 | "3gpp", 28 | "ts", 29 | "iso", 30 | "mov", 31 | "rmvb", 32 | } 33 | source = []string{ 34 | "web-dl", 35 | "blu-ray", 36 | "bluray", 37 | "hdtv", 38 | "cctvhd", 39 | } 40 | studio = []string{ 41 | "hmax", 42 | "netflix", 43 | "funimation", 44 | "amzn", 45 | "hulu", 46 | "kktv", 47 | "crunchyroll", 48 | "bbc", 49 | } 50 | tmpSuffix = []string{ 51 | ".part", 52 | ".!qB", 53 | ".!qb", 54 | ".!ut", 55 | } 56 | delimiter = []string{ 57 | "-", 58 | ".", 59 | ",", 60 | "_", 61 | " ", 62 | "[", 63 | "]", 64 | "(", 65 | ")", 66 | "{", 67 | "}", 68 | "@", 69 | ":", 70 | ":", 71 | } 72 | delimiterExecute = []string{ // todo 使用单独维护的音频编码、视频编码、制作组等 73 | "WEB-DL", 74 | "DDP5.1", 75 | "DDP 5.1", 76 | "DDP.5.1", 77 | "H.265", 78 | "H265", 79 | "BLU-RAY", 80 | "MA5.1", 81 | "MA 5.1", 82 | "MA.5.1", 83 | "MA7.1", 84 | "MA 7.1", 85 | "MA.7.1", 86 | "DTS-HD", 87 | "HDR", 88 | "SDR", 89 | "DV", 90 | } 91 | channel = []string{ 92 | "OAD", 93 | "OVA", 94 | "BD", 95 | "DVD", 96 | "SP", 97 | } 98 | videoCoding = []string{ 99 | "H.265", 100 | "H265", 101 | "H.264", 102 | "H264", 103 | "H.263", 104 | "H.261", 105 | "x265", 106 | "x264", 107 | "AVC", 108 | "MPEG", 109 | "av1", 110 | "HEVC", 111 | } 112 | audioCoding = []string{ 113 | "ac3", 114 | "aac", 115 | "dts", 116 | "dts-hd", 117 | "e-ac-3", 118 | "ddp 5.1", 119 | "ddp5.1", 120 | } 121 | dynamicRange = []string{ 122 | "hdr", 123 | "sdr", 124 | "dv", 125 | } 126 | crew = []string{ 127 | "ADWeb", 128 | "Audies", 129 | "ADE", 130 | "ADAudio", 131 | "CMCT", 132 | "CMCTA", 133 | "CMCTV", 134 | "Oldboys", 135 | "GTR", 136 | "OurBits", 137 | "OurTV", 138 | "iLoveTV", 139 | "iLoveHD", 140 | "MTeam", 141 | "MWeb", 142 | "BMDru", 143 | "QHstudio", 144 | "HDCTV", 145 | "HDArea", 146 | "HDAccess", 147 | "WiKi", 148 | "TTG", 149 | "CHD", 150 | "beAst", 151 | "DTime", 152 | "HHWEB", 153 | "NoVA", 154 | "NoPA", 155 | "NoXA", 156 | "HDSky", 157 | "HDS", 158 | "HDSTV", 159 | "HDSWEB", 160 | "HDSPad", 161 | "HDS3D", 162 | "HDHome", 163 | "HDH", 164 | "HDHTV", 165 | "HDHWEB", 166 | "AGSVPT", 167 | "AGSVWEB", 168 | } 169 | videoMap = map[string]struct{}{} 170 | sourceMap = map[string]struct{}{} 171 | studioMap = map[string]struct{}{} 172 | delimiterMap = map[string]struct{}{} 173 | channelMap = map[string]struct{}{} 174 | videoCodingMap = map[string]struct{}{} 175 | audioCodingMap = map[string]struct{}{} 176 | dynamicRangeMap = map[string]struct{}{} 177 | crewMap = map[string]struct{}{} 178 | 179 | chsNumber = map[string]int{ 180 | "零": 0, 181 | "一": 1, 182 | "二": 2, 183 | "三": 3, 184 | "四": 4, 185 | "五": 5, 186 | "六": 6, 187 | "七": 7, 188 | "八": 8, 189 | "九": 9, 190 | "十": 10, 191 | } 192 | chsNumberUnit = map[string]int{ 193 | "十": 10, 194 | "百": 100, 195 | "千": 1000, 196 | "万": 10000, 197 | "亿": 100000000, 198 | } 199 | chsMatch *regexp.Regexp 200 | chsSeasonMatch *regexp.Regexp 201 | chsEpisodeMatch *regexp.Regexp 202 | 203 | episodeMatch *regexp.Regexp 204 | episodeMatchAlone *regexp.Regexp 205 | collectionMatch *regexp.Regexp 206 | subEpisodesMatch *regexp.Regexp 207 | yearRangeLikeMatch *regexp.Regexp 208 | yearRangeMatch *regexp.Regexp 209 | yearMatch *regexp.Regexp 210 | formatMatch *regexp.Regexp 211 | seasonMatch *regexp.Regexp 212 | optionsMatch *regexp.Regexp 213 | resolutionMatch *regexp.Regexp 214 | seasonRangeMatch *regexp.Regexp 215 | partMatch *regexp.Regexp 216 | numberMatch *regexp.Regexp 217 | ) 218 | 219 | func init() { 220 | for _, item := range video { 221 | videoMap[item] = struct{}{} 222 | } 223 | 224 | for _, item := range source { 225 | sourceMap[item] = struct{}{} 226 | } 227 | 228 | for _, item := range studio { 229 | studioMap[strings.ToUpper(item)] = struct{}{} 230 | } 231 | 232 | for _, item := range delimiter { 233 | delimiterMap[strings.ToUpper(item)] = struct{}{} 234 | } 235 | 236 | for _, item := range channel { 237 | channelMap[strings.ToUpper(item)] = struct{}{} 238 | } 239 | 240 | for _, item := range videoCoding { 241 | videoCodingMap[strings.ToUpper(item)] = struct{}{} 242 | } 243 | 244 | for _, item := range audioCoding { 245 | audioCodingMap[strings.ToUpper(item)] = struct{}{} 246 | } 247 | 248 | for _, item := range dynamicRange { 249 | dynamicRangeMap[strings.ToUpper(item)] = struct{}{} 250 | } 251 | 252 | for _, item := range crew { 253 | crewMap[strings.ToUpper(item)] = struct{}{} 254 | } 255 | 256 | episodeMatch, _ = regexp.Compile(`(?i)((第|s|season)\s*(\d+).*?季?)?(第|e|p|ep|episode)\s*(\d+).+$`) 257 | episodeMatchAlone, _ = regexp.Compile(`(?i)(第|e|p|ep|episode)\s*(\d+).+$`) 258 | collectionMatch, _ = regexp.Compile("[sS](0|)[0-9]+-[sS](0|)[0-9]+") 259 | subEpisodesMatch, _ = regexp.Compile("[eE](0|)[0-9]+-[eE](0|)[0-9]+") 260 | yearRangeLikeMatch, _ = regexp.Compile("[12][0-9]{3}-[12][0-9]{3}") 261 | yearRangeMatch, _ = regexp.Compile("[12][0-9]{3}-[12][0-9]{3}") 262 | yearMatch, _ = regexp.Compile("^[12][0-9]{3}$") 263 | formatMatch, _ = regexp.Compile("([0-9]+[pPiI]|[24][kK])") 264 | seasonMatch, _ = regexp.Compile(`(?i)(第|s|S|Season)\s*(\d+)(季|)(.+)?$`) 265 | optionsMatch, _ = regexp.Compile(`\[.*?\](\.)?`) 266 | chsMatch, _ = regexp.Compile("(?:第|)([零一二三四五六七八九十百千万亿]+)[季|集]") 267 | chsSeasonMatch, _ = regexp.Compile(`(.*?)(\.|)第([0-9]+)([-至到])?([0-9]+)?季`) 268 | chsEpisodeMatch, _ = regexp.Compile("(?:第|)([0-9]+)集") 269 | resolutionMatch, _ = regexp.Compile("[0-9]{3,4}Xx*[0-9]{3,4}") 270 | seasonRangeMatch, _ = regexp.Compile("[sS](0|)[0-9]+-[sS](0|)[0-9]+") 271 | partMatch, _ = regexp.Compile("(:?.|-|_| |@)[pP]art([0-9])(:?.|-|_| |@)") 272 | numberMatch, _ = regexp.Compile("([0-9]+).+$") 273 | } 274 | 275 | // IsCollection 是否是合集,如S01-S03季 276 | func IsCollection(name string) bool { 277 | return collectionMatch.MatchString(name) || yearRangeMatch.MatchString(name) 278 | } 279 | 280 | // IsSubEpisodes 是否是分段集,如:World.Heritage.In.China.E01-E38.2008.CCTVHD.x264.AC3.720p-CMCT 281 | // 常见于持续更新中的 282 | func IsSubEpisodes(name string) string { 283 | return subEpisodesMatch.FindString(name) 284 | } 285 | 286 | // IsVideo 是否是视频文件,根据后缀枚举 287 | func IsVideo(name string) string { 288 | split := strings.Split(name, ".") 289 | if len(split) == 0 { 290 | return "" 291 | } 292 | 293 | suffix := strings.ToLower(split[len(split)-1]) 294 | if _, ok := videoMap[suffix]; ok { 295 | return suffix 296 | } 297 | 298 | return "" 299 | } 300 | 301 | // IsYearRangeLike 判断并返回年范围,用于合集 302 | func IsYearRangeLike(name string) string { 303 | return yearRangeLikeMatch.FindString(name) 304 | } 305 | 306 | // IsYearRange 判断并返回年范围,用于合集 307 | func IsYearRange(name string) string { 308 | return yearRangeMatch.FindString(name) 309 | } 310 | 311 | // IsYear 判断是否是年份 312 | func IsYear(name string) int { 313 | if !yearMatch.MatchString(name) { 314 | return 0 315 | } 316 | 317 | year, _ := strconv.Atoi(name) 318 | 319 | return year 320 | } 321 | 322 | // IsSeasonRange 判断并返回合集 323 | func IsSeasonRange(name string) string { 324 | return seasonRangeMatch.FindString(name) 325 | } 326 | 327 | // IsSeason 判断并返回季,可能和名字写在一起,所以使用子串,如:黄石S01.Yellowstone.2018.1080p 328 | func IsSeason(name string) (string, string) { 329 | find := seasonMatch.FindStringSubmatch(name) 330 | if len(find) > 0 { 331 | return find[0], find[2] 332 | } 333 | 334 | return name, "" 335 | } 336 | 337 | // IsEpisode 判断并返回集,如果文件名带数字 338 | func IsEpisode(name string) (string, string) { 339 | find := episodeMatchAlone.FindStringSubmatch(name) 340 | if len(find) > 0 { 341 | return find[0], find[2] 342 | } 343 | return name, "" 344 | } 345 | 346 | // IsFormat 判断并返回格式,可能放在结尾,所以使用子串,如:World.Heritage.In.China.E01-E38.2008.CCTVHD.x264.AC3.720p-CMCT 347 | func IsFormat(name string) string { 348 | return formatMatch.FindString(name) 349 | } 350 | 351 | // IsSource 片源 352 | func IsSource(name string) string { 353 | if _, ok := sourceMap[strings.ToLower(name)]; ok { 354 | return name 355 | } 356 | return "" 357 | } 358 | 359 | // IsStudio 发行公司 360 | func IsStudio(name string) string { 361 | if _, ok := studioMap[strings.ToLower(name)]; ok { 362 | return name 363 | } 364 | return "" 365 | } 366 | 367 | // IsChannel 发行渠道 368 | func IsChannel(name string) string { 369 | if _, ok := channelMap[strings.ToUpper(name)]; ok { 370 | return name 371 | } 372 | return "" 373 | } 374 | 375 | // IsVideoCoding 视频编码器 376 | func IsVideoCoding(name string) string { 377 | if _, ok := videoCodingMap[strings.ToUpper(name)]; ok { 378 | return name 379 | } 380 | return "" 381 | } 382 | 383 | // IsAudioCoding 音频编码 384 | func IsAudioCoding(name string) string { 385 | if _, ok := audioCodingMap[strings.ToUpper(name)]; ok { 386 | return name 387 | } 388 | return "" 389 | } 390 | 391 | // IsDynamicRange 动态范围 392 | func IsDynamicRange(name string) string { 393 | if _, ok := dynamicRangeMap[strings.ToUpper(name)]; ok { 394 | return name 395 | } 396 | return "" 397 | } 398 | 399 | // IsCrew 制作组 400 | func IsCrew(name string) string { 401 | if _, ok := crewMap[strings.ToUpper(name)]; ok { 402 | return name 403 | } 404 | return "" 405 | } 406 | 407 | // SplitChsEngTitle 分离中英文名字, 不兼容中英文混编,如: 我love你 408 | func SplitChsEngTitle(name string) (string, string) { 409 | name = strings.Replace(name, "[", "", -1) 410 | name = strings.Replace(name, "]", "", -1) 411 | name = strings.Replace(name, "{", "", -1) 412 | name = strings.Replace(name, "}", "", -1) 413 | name = strings.Trim(name, " ") 414 | 415 | //chsFind := false 416 | chsName := "" 417 | split := strings.Split(name, " ") 418 | for _, item := range split { 419 | r := []rune(item) 420 | //if item == "" || unicode.Is(unicode.Han, r[0]) || (chsFind && unicode.IsDigit(r[0])) { 421 | if item == "" || unicode.Is(unicode.Han, r[0]) { 422 | //chsFind = true 423 | chsName += item + " " 424 | continue 425 | } else { 426 | break 427 | } 428 | } 429 | 430 | chsName = strings.TrimSpace(chsName) 431 | engName := strings.TrimSpace(strings.Replace(name, chsName, "", 1)) 432 | 433 | return chsName, engName 434 | } 435 | 436 | func SplitTitleAlias(name string) (string, string) { 437 | split := strings.Split(name, " AKA ") 438 | if len(split) == 2 { 439 | return split[0], split[1] 440 | } 441 | return name, "" 442 | } 443 | 444 | // MatchEpisode 匹配季和集 445 | func MatchEpisode(name string) (int, int) { 446 | find := episodeMatch.FindStringSubmatch(name) 447 | if len(find) == 6 { 448 | return StrToInt(find[3]), StrToInt(find[5]) 449 | } 450 | 451 | return 0, 0 452 | } 453 | 454 | // FilterTmpSuffix 过滤临时文件后缀,部分软件会在未完成的文件后面增加后缀 455 | func FilterTmpSuffix(name string) string { 456 | if !config.Collector.FilterTmpSuffix || len(config.Collector.TmpSuffix) == 0 { 457 | return name 458 | } 459 | 460 | for _, tmp := range tmpSuffix { 461 | for _, suffix := range video { 462 | name = strings.Replace(name, suffix+tmp, suffix, 1) 463 | } 464 | } 465 | 466 | return name 467 | } 468 | 469 | // FilterOptionals 过滤掉可选的字符: 被中括号[]包围的 470 | // 若是过滤完后为空,可能直接使用[]分段,尝试只过滤第一个 471 | func FilterOptionals(name string) string { 472 | clear := optionsMatch.ReplaceAllString(name, "") 473 | if clear != "" { 474 | return clear 475 | } 476 | 477 | find := optionsMatch.FindStringSubmatch(name) 478 | if len(find) == 2 { 479 | clear = strings.Replace(name, find[0], "", 1) 480 | } 481 | 482 | return clear 483 | } 484 | 485 | // CoverChsNumber 中文数字替换为阿拉伯数字 486 | func CoverChsNumber(number string) int { 487 | sum := 0 488 | temp := 0 489 | runes := []rune(number) 490 | for i := 0; i < len(runes); i++ { 491 | char := string(runes[i]) 492 | if char == "零" { 493 | continue 494 | } 495 | 496 | if char == "亿" || char == "万" { // 特殊的权位数字,不会再累加了,其他的十、百、千可能会继续累加,比如八百一十二万 497 | sum += temp * chsNumberUnit[char] 498 | temp = 0 499 | } else { 500 | if i+1 < len(runes) { // 还没有到最后 501 | nextChar := string(runes[i+1]) 502 | if unit, ok := chsNumberUnit[nextChar]; ok { // 下一位是权位数字 503 | if nextChar != "亿" && nextChar != "万" { 504 | temp += chsNumber[char] * unit 505 | i++ 506 | continue 507 | } 508 | } else { // 还没有到最后,但是下一位却不是权位数字,那自己就是权位数字,比如十二 509 | temp += 10 510 | continue 511 | } 512 | } 513 | 514 | temp += chsNumber[char] 515 | } 516 | } 517 | 518 | return sum + temp 519 | } 520 | 521 | // ReplaceChsNumber 替换字符里面的中文数字为阿拉伯数字 522 | func ReplaceChsNumber(name string) string { 523 | for { 524 | find := chsMatch.FindStringSubmatch(name) 525 | if len(find) == 0 { 526 | break 527 | } 528 | 529 | number := strconv.Itoa(CoverChsNumber(find[1])) 530 | name = strings.Replace(name, find[1], number, 1) 531 | } 532 | 533 | return name 534 | } 535 | 536 | // SeasonCorrecting 中文季纠正 537 | func SeasonCorrecting(name string) string { 538 | name = ReplaceChsNumber(name) 539 | right := "" 540 | find := chsSeasonMatch.FindStringSubmatch(name) 541 | if len(find) == 6 { 542 | if find[4] == "" && find[5] == "" { 543 | num, err := strconv.Atoi(find[3]) 544 | if err == nil && num > 0 { 545 | right = fmt.Sprintf("S%.2d", num) 546 | } 547 | } else { 548 | num1, err := strconv.Atoi(find[3]) 549 | num2, err := strconv.Atoi(find[5]) 550 | if err == nil && num1 > 0 && num2 > 0 { 551 | right = fmt.Sprintf("S%.2d-S%.2d", num1, num2) 552 | } 553 | } 554 | 555 | if right != "" { 556 | name = strings.Replace(name, find[0], find[1]+"."+right, 1) 557 | } 558 | } 559 | 560 | return name 561 | } 562 | 563 | // EpisodeCorrecting 中文集纠正 564 | func EpisodeCorrecting(name string) string { 565 | name = ReplaceChsNumber(name) 566 | find := chsEpisodeMatch.FindStringSubmatch(name) 567 | if len(find) == 2 { 568 | number, err := strconv.Atoi(find[1]) 569 | if err == nil { 570 | name = strings.Replace(name, find[0], fmt.Sprintf("E%02d", number), 1) 571 | } 572 | } 573 | 574 | return name 575 | } 576 | 577 | // IsResolution 分辨率 578 | func IsResolution(name string) string { 579 | return resolutionMatch.FindString(name) 580 | } 581 | 582 | // Split 影视目录或文件名切割 583 | func Split(name string) []string { 584 | return lrace.StringSplitWith(name, delimiter, delimiterExecute) 585 | } 586 | 587 | // MatchPart 匹配分卷 588 | func MatchPart(name string) int { 589 | find := partMatch.FindStringSubmatch(name) 590 | if len(find) == 4 { 591 | num, err := strconv.Atoi(find[2]) 592 | if err == nil { 593 | return num 594 | } 595 | } 596 | return 0 597 | } 598 | -------------------------------------------------------------------------------- /utils/video_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/fengqi/lrace" 5 | "testing" 6 | ) 7 | 8 | func TestMatchEpisode(t *testing.T) { 9 | cases := map[string][]int{ 10 | "[堕落].The.Fall.2013.S02.E03.Complete.BluRay.720p.x264.AC3-CMCT.mkv": {2, 3}, 11 | "Agent.Carter.S02E11.1080p.BluRay.DD5.1.x264-HDS.mkv": {2, 11}, 12 | "[壹高清]21点灵.Leave No Soul Behind.S01Ep03.HDTV.1080p.H264-OneHD.ts": {1, 3}, 13 | "Kimetsu.no.Yaiba.Yuukaku-hen.S01.E05.2021.Crunchyroll.WEB-DL.1080p.x264.AAC-HDCTV.mkv": {1, 5}, 14 | "宝贝揪揪 第3季 第10集.mp4": {3, 10}, 15 | //"宝贝揪揪 第9集.mp4": {1, 9}, 16 | "宝贝揪揪 s01 p02.mp4": {1, 2}, 17 | "宝贝揪揪 s01xe02.mp4": {1, 2}, 18 | "宝贝揪揪 s01-e02.mp4": {1, 2}, 19 | //"Gannibal.E16.2022.mp4": {1, 16}, 20 | "Gannibal S02 E11 2022.mp4": {2, 11}, 21 | "Gannibal-S01-E02-2022.mp4": {1, 2}, 22 | "Gannibal.Season02.EP03.2022.mp4": {2, 3}, 23 | //"转生成自动贩卖机02全片简中.mp4": {1, 2}, 24 | "地球脉动.第3季.Planet.Earth.S03E02.2023.2160p.WEB-DL.H265.10bit.DDP2.0.2Audio-OurTV.mp4": {3, 2}, 25 | //"E01.mkv": {1, 1}, 26 | //"E02.mkv": {1, 2}, 27 | "S01E01.mp4": {1, 1}, 28 | } 29 | for name, cse := range cases { 30 | s, e := MatchEpisode(name) 31 | if s != cse[0] { 32 | t.Errorf("MatchEpisode(%s)\n season %d; expected %d", name, s, cse[0]) 33 | } 34 | if e != cse[1] { 35 | t.Errorf("MatchEpisode(%s)\n episode %d; expected %d", name, e, cse[1]) 36 | } 37 | 38 | } 39 | } 40 | 41 | func TestIsFormat(t *testing.T) { 42 | unit := map[string]string{ 43 | "720": "", 44 | "a720p": "720p", 45 | "720P": "720P", 46 | "1080p": "1080p", 47 | "1080P": "1080P", 48 | "2k": "2k", 49 | "2K": "2K", 50 | "4K": "4K", 51 | "720p-CMCT": "720p", 52 | "-720p-CMCT": "720p", 53 | } 54 | 55 | for k, v := range unit { 56 | actual := IsFormat(k) 57 | if actual != v { 58 | t.Errorf("isFormat(%s) = %s; expected %s", k, actual, v) 59 | } 60 | } 61 | } 62 | 63 | func TestIsSeason(t *testing.T) { 64 | unit := map[string]string{ 65 | "第2季": "2", 66 | "s01": "01", 67 | "S01": "01", 68 | "s1": "1", 69 | "S2": "2", 70 | "S100": "100", 71 | "4K": "", 72 | "Fall.in.Love.2021.WEB-DL.4k.H265.10bit.AAC-HDCTV FallinLove ": "", 73 | "Hawkeye.2021S01.Never.Meet.Your.Heroes.2160p": "01", 74 | "Season 2": "2", 75 | } 76 | 77 | for k, v := range unit { 78 | _, actual := IsSeason(k) 79 | if actual != v { 80 | t.Errorf("isSeason(%s) = %s; expected %s", k, actual, v) 81 | } 82 | } 83 | } 84 | 85 | func TestSplit(t *testing.T) { 86 | unit := map[string][]string{ 87 | "[梦蓝字幕组]Crayonshinchan 蜡笔小新[1105][2021.11.06][AVC][1080P][GB_JP][MP4]V2.mp4": { 88 | "梦蓝字幕组", 89 | "Crayonshinchan", 90 | "蜡笔小新", 91 | "1105", 92 | "2021", 93 | "11", 94 | "06", 95 | "AVC", 96 | "1080P", 97 | "GB", 98 | "JP", 99 | "MP4", 100 | "V2", 101 | "mp4", 102 | }, 103 | "The Last Son 2021.mkv": { 104 | "The", 105 | "Last", 106 | "Son", 107 | "2021", 108 | "mkv", 109 | }, 110 | "Midway 2019 2160p CAN UHD Blu-ray HEVC DTS-HD MA 5.1-THDBST@HDSky.nfo": { 111 | "Midway", 112 | "2019", 113 | "2160p", 114 | "CAN", 115 | "UHD", 116 | "Blu-ray", 117 | "HEVC", 118 | "DTS-HD", 119 | "MA 5.1", 120 | "THDBST", 121 | "HDSky", 122 | "nfo", 123 | }, 124 | } 125 | 126 | for k, v := range unit { 127 | actual := Split(k) 128 | if !lrace.ArrayCompare(actual, v, false) { 129 | t.Errorf("Split(%s) = %v; expected %v", k, actual, v) 130 | } 131 | } 132 | } 133 | 134 | func TestCleanTitle(t *testing.T) { 135 | cases := map[string][]string{ 136 | "北区侦缉队 The Stronghold": {"北区侦缉队", "The Stronghold"}, 137 | "兴风作浪2 Trouble Makers": {"兴风作浪2", "Trouble Makers"}, 138 | "Tick Tick BOOM": {"", "Tick Tick BOOM"}, 139 | "比得兔2:逃跑计划": {"比得兔2:逃跑计划", ""}, 140 | "龙威山庄 99 Cycling Swords": {"龙威山庄", "99 Cycling Swords"}, 141 | "我love你": {"我love你", ""}, 142 | "我love 你": {"我love 你", ""}, 143 | } 144 | 145 | for title, want := range cases { 146 | chs, eng := SplitChsEngTitle(title) 147 | if chs != want[0] || eng != want[1] { 148 | t.Errorf("CleanTitle(%s) = %s-%s; want %s", title, chs, eng, want) 149 | } 150 | } 151 | } 152 | 153 | func TestCoverChsNumber(t *testing.T) { 154 | cases := map[string]int{ 155 | "零": 0, 156 | "一": 1, 157 | "二": 2, 158 | "三": 3, 159 | "四": 4, 160 | "五": 5, 161 | "六": 6, 162 | "七": 7, 163 | "八": 8, 164 | "九": 9, 165 | "十": 10, 166 | "十一": 11, 167 | "十二": 12, 168 | "一十二": 12, 169 | "二十二": 22, 170 | "九十二": 92, 171 | "一百九十二": 192, 172 | "三千一百一十二": 3112, 173 | "三千一百九十二": 3192, 174 | "五万三千一百九十二": 53192, 175 | "五万零一百九十二": 50192, 176 | "五十三万零一百九十二": 530192, 177 | "五百万零一百九十二": 5000192, 178 | "四十二亿九千四百九十六万七千二百九十五": 4294967295, 179 | } 180 | for number, want := range cases { 181 | give := CoverChsNumber(number) 182 | if give != want { 183 | t.Errorf("CoverZhsNumber(%s) give %d, want %d", number, give, want) 184 | } 185 | } 186 | } 187 | 188 | func TestReplaceChsNumber(t *testing.T) { 189 | cases := map[string]string{ 190 | "第一季": "第1季", 191 | "第一集": "第1集", 192 | "第十一集": "第11集", 193 | "十一集": "11集", 194 | "二": "二", 195 | "一百九十二": "一百九十二", 196 | } 197 | for number, want := range cases { 198 | give := ReplaceChsNumber(number) 199 | if give != want { 200 | t.Errorf("ReplaceChsNumber(%s) give %s, want %s", number, give, want) 201 | } 202 | } 203 | } 204 | 205 | func TestSeasonCorrecting(t *testing.T) { 206 | cases := map[string]string{ 207 | "邪恶力量.第01-14季.Supernatural.S01-S14.1080p.Blu-Ray.AC3.x265.10bit-Yumi": "邪恶力量.S01-S14.Supernatural.S01-S14.1080p.Blu-Ray.AC3.x265.10bit-Yumi", 208 | "堕落.第一季.2013.中英字幕£CMCT无影": "堕落.S01.2013.中英字幕£CMCT无影", 209 | "一年一度喜剧大赛": "一年一度喜剧大赛", 210 | "亿万富犬.第一季": "亿万富犬.S01", 211 | "超级宝贝JOJO第二季": "超级宝贝JOJO.S02", 212 | } 213 | 214 | for title, want := range cases { 215 | give := SeasonCorrecting(title) 216 | if give != want { 217 | t.Errorf("SeasonCorrecting(%s) give: %s, want %s", title, give, want) 218 | } 219 | } 220 | } 221 | 222 | func TestEpisodeCorrecting(t *testing.T) { 223 | cases := map[string]string{ 224 | "宝贝揪揪 第三季 第09集.mp4": "宝贝揪揪 第3季 E09.mp4", 225 | "宝贝揪揪 第三季 第01集.mp4": "宝贝揪揪 第3季 E01.mp4", 226 | "宝贝揪揪 第三季 第十集.mp4": "宝贝揪揪 第3季 E10.mp4", 227 | } 228 | 229 | for title, want := range cases { 230 | give := EpisodeCorrecting(title) 231 | if give != want { 232 | t.Errorf("SeasonCorrecting(%s) give: %s, want %s", title, give, want) 233 | } 234 | } 235 | } 236 | 237 | func TestIsCollection(t *testing.T) { 238 | cases := map[string]bool{ 239 | "邪恶力量.第01-14季.Supernatural.S01-S14.1080p.Blu-Ray.AC3.x265.10bit-Yumi": true, 240 | "外星也难民S01.Solar.Opposites.2020.1080p.WEB-DL.x265.AC3£cXcY@FRDS": false, 241 | "Heroes.S01-04.2006-2009.Complete.1080p.Amazon.Webdl.AVC.DDP5.1-DBTV": true, 242 | } 243 | 244 | for title, want := range cases { 245 | give := IsCollection(title) 246 | if give != want { 247 | t.Errorf("IsCollection(%s) give: %v, want %v", title, give, want) 248 | } 249 | } 250 | } 251 | 252 | func TestIsEpisode(t *testing.T) { 253 | type args struct { 254 | name string 255 | } 256 | tests := []struct { 257 | name string 258 | filename string 259 | match string 260 | episode string 261 | }{ 262 | { 263 | name: "s02e03", 264 | filename: "The.Day.of.the.Jackal.S02E03.2024.2160p.PCOK.WEB-DL.H265.HDR.DDP5.1-ADWeb.mkv", 265 | match: "E03.2024.2160p.PCOK.WEB-DL.H265.HDR.DDP5.1-ADWeb.mkv", 266 | episode: "03", 267 | }, 268 | { 269 | name: "s02e03", 270 | filename: "第三集.mkv", 271 | match: "E03.mkv", 272 | episode: "03", 273 | }, 274 | { 275 | name: "e02", 276 | filename: "p02.mkv", 277 | match: "p02.mkv", 278 | episode: "02", 279 | }, 280 | { 281 | name: "ep02", 282 | filename: "ep02.mkv", 283 | match: "ep02.mkv", 284 | episode: "02", 285 | }, 286 | { 287 | name: "episode02", 288 | filename: "episode02.mkv", 289 | match: "episode02.mkv", 290 | episode: "02", 291 | }, 292 | } 293 | for _, tt := range tests { 294 | t.Run(tt.name, func(t *testing.T) { 295 | filename := tt.filename 296 | filename = ReplaceChsNumber(filename) 297 | filename = SeasonCorrecting(filename) 298 | filename = EpisodeCorrecting(filename) 299 | match, episode := IsEpisode(filename) 300 | if episode != tt.episode { 301 | t.Errorf("IsEpisode() give = %v, want %v", episode, tt.episode) 302 | } 303 | if match != tt.match { 304 | t.Errorf("IsEpisode() give = %v, want %v", match, tt.match) 305 | } 306 | }) 307 | } 308 | } 309 | --------------------------------------------------------------------------------