scan_played_video_subinfo.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. package scan_played_video_subinfo
  2. import (
  3. "errors"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "strings"
  8. "sync"
  9. "github.com/allanpk716/ChineseSubFinder/internal/logic/file_downloader"
  10. "github.com/allanpk716/ChineseSubFinder/internal/pkg/mix_media_info"
  11. "github.com/allanpk716/ChineseSubFinder/internal/dao"
  12. "github.com/allanpk716/ChineseSubFinder/internal/ifaces"
  13. embyHelper "github.com/allanpk716/ChineseSubFinder/internal/logic/emby_helper"
  14. "github.com/allanpk716/ChineseSubFinder/internal/logic/sub_parser/ass"
  15. "github.com/allanpk716/ChineseSubFinder/internal/logic/sub_parser/srt"
  16. "github.com/allanpk716/ChineseSubFinder/internal/models"
  17. "github.com/allanpk716/ChineseSubFinder/internal/pkg/decode"
  18. "github.com/allanpk716/ChineseSubFinder/internal/pkg/imdb_helper"
  19. "github.com/allanpk716/ChineseSubFinder/internal/pkg/language"
  20. "github.com/allanpk716/ChineseSubFinder/internal/pkg/my_folder"
  21. "github.com/allanpk716/ChineseSubFinder/internal/pkg/my_util"
  22. "github.com/allanpk716/ChineseSubFinder/internal/pkg/settings"
  23. "github.com/allanpk716/ChineseSubFinder/internal/pkg/sub_file_hash"
  24. "github.com/allanpk716/ChineseSubFinder/internal/pkg/sub_formatter/emby"
  25. "github.com/allanpk716/ChineseSubFinder/internal/pkg/sub_parser_hub"
  26. "github.com/allanpk716/ChineseSubFinder/internal/pkg/sub_share_center"
  27. "github.com/allanpk716/ChineseSubFinder/internal/pkg/task_control"
  28. "github.com/allanpk716/ChineseSubFinder/internal/types"
  29. "github.com/allanpk716/ChineseSubFinder/internal/types/common"
  30. "github.com/huandu/go-clone"
  31. "github.com/sirupsen/logrus"
  32. "golang.org/x/net/context"
  33. )
  34. type ScanPlayedVideoSubInfo struct {
  35. settings *settings.Settings
  36. log *logrus.Logger
  37. fileDownloader *file_downloader.FileDownloader
  38. embyHelper *embyHelper.EmbyHelper
  39. taskControl *task_control.TaskControl
  40. canceled bool
  41. canceledLock sync.Mutex
  42. subParserHub *sub_parser_hub.SubParserHub
  43. movieSubMap map[string]string
  44. seriesSubMap map[string]string
  45. subFormatter ifaces.ISubFormatter
  46. shareRootDir string
  47. imdbInfoCache map[string]*models.IMDBInfo
  48. cacheImdbInfoCacheLocker sync.Mutex
  49. }
  50. func NewScanPlayedVideoSubInfo(log *logrus.Logger, _settings *settings.Settings, fileDownloader *file_downloader.FileDownloader) (*ScanPlayedVideoSubInfo, error) {
  51. var err error
  52. var scanPlayedVideoSubInfo ScanPlayedVideoSubInfo
  53. scanPlayedVideoSubInfo.log = log
  54. // 下载实例
  55. scanPlayedVideoSubInfo.fileDownloader = fileDownloader
  56. // 参入设置信息
  57. // 最大获取的视频数目设置到 100W
  58. scanPlayedVideoSubInfo.settings = clone.Clone(_settings).(*settings.Settings)
  59. scanPlayedVideoSubInfo.settings.EmbySettings.MaxRequestVideoNumber = 1000000
  60. // 检测是否某些参数超出范围
  61. scanPlayedVideoSubInfo.settings.Check()
  62. // 初始化 Emby API 接口
  63. if scanPlayedVideoSubInfo.settings.EmbySettings.Enable == true && scanPlayedVideoSubInfo.settings.EmbySettings.AddressUrl != "" &&
  64. scanPlayedVideoSubInfo.settings.EmbySettings.APIKey != "" {
  65. scanPlayedVideoSubInfo.embyHelper = embyHelper.NewEmbyHelper(log, scanPlayedVideoSubInfo.settings)
  66. }
  67. // 初始化任务控制
  68. scanPlayedVideoSubInfo.taskControl, err = task_control.NewTaskControl(scanPlayedVideoSubInfo.settings.CommonSettings.Threads, log)
  69. if err != nil {
  70. return nil, err
  71. }
  72. // 字幕解析器
  73. scanPlayedVideoSubInfo.subParserHub = sub_parser_hub.NewSubParserHub(log, ass.NewParser(log), srt.NewParser(log))
  74. // 字幕命名格式解析器
  75. scanPlayedVideoSubInfo.subFormatter = emby.NewFormatter()
  76. // 缓存目录的根目录
  77. shareRootDir, err := my_folder.GetShareSubRootFolder()
  78. if err != nil {
  79. return nil, err
  80. }
  81. scanPlayedVideoSubInfo.shareRootDir = shareRootDir
  82. // 初始化缓存
  83. scanPlayedVideoSubInfo.imdbInfoCache = make(map[string]*models.IMDBInfo)
  84. return &scanPlayedVideoSubInfo, nil
  85. }
  86. func (s *ScanPlayedVideoSubInfo) Cancel() {
  87. defer func() {
  88. s.log.Infoln("ScanPlayedVideoSubInfo.Cancel()")
  89. }()
  90. s.canceledLock.Lock()
  91. s.canceled = true
  92. s.canceledLock.Unlock()
  93. s.taskControl.Release()
  94. }
  95. func (s *ScanPlayedVideoSubInfo) GetPlayedItemsSubtitle() (bool, error) {
  96. var err error
  97. // 是否是通过 emby_helper api 获取的列表
  98. if s.embyHelper == nil {
  99. // 没有填写 emby_helper api 的信息,那么就跳过
  100. s.log.Infoln("Skip ScanPlayedVideoSubInfo, Emby Settings is null")
  101. return false, nil
  102. }
  103. s.movieSubMap, s.seriesSubMap, err = s.embyHelper.GetPlayedItemsSubtitle()
  104. if err != nil {
  105. return false, err
  106. }
  107. return true, nil
  108. }
  109. // Clear 清理无效的缓存字幕信息
  110. func (s *ScanPlayedVideoSubInfo) Clear() {
  111. defer func() {
  112. s.log.Infoln("ScanPlayedVideoSubInfo.Clear Sub End")
  113. s.log.Infoln("----------------------------------------------")
  114. }()
  115. s.log.Infoln("-----------------------------------------------")
  116. s.log.Infoln("ScanPlayedVideoSubInfo.Clear Sub Start...")
  117. var imdbInfos []models.IMDBInfo
  118. // 把嵌套关联的 has many 的信息都查询出来
  119. dao.GetDb().Preload("VideoSubInfos").Find(&imdbInfos)
  120. // 同时需要把不在数据库记录的字幕给删除,那么就需要把数据库查询出来的给做成 map
  121. dbSubMap := make(map[string]int)
  122. for _, info := range imdbInfos {
  123. for _, oneSubInfo := range info.VideoSubInfos {
  124. s.log.Infoln("ScanPlayedVideoSubInfo.Clear Sub", oneSubInfo.SubName)
  125. // 转换到绝对路径
  126. cacheSubFPath := filepath.Join(s.shareRootDir, oneSubInfo.StoreRPath)
  127. if my_util.IsFile(cacheSubFPath) == false {
  128. // 如果文件不存在,那么就删除之前的关联
  129. // 关联删除了,但是不会删除这些对象,所以后续还需要再次删除
  130. s.delSubInfo(&info, &oneSubInfo)
  131. s.log.Debugln("ScanPlayedVideoSubInfo.Clear Sub delSubInfo", oneSubInfo.SubName)
  132. continue
  133. }
  134. dbSubMap[oneSubInfo.StoreRPath] = 0
  135. }
  136. }
  137. // 搜索缓存文件夹所有的字幕出来,对比上面的 map 进行比较
  138. subFiles, err := sub_parser_hub.SearchMatchedSubFile(s.log, s.shareRootDir)
  139. if err != nil {
  140. return
  141. }
  142. for _, file := range subFiles {
  143. subRelPath, err := filepath.Rel(s.shareRootDir, file)
  144. if err != nil {
  145. s.log.Warningln("ScanPlayedVideoSubInfo.Scan.Rel", file, err)
  146. continue
  147. }
  148. _, bok := dbSubMap[subRelPath]
  149. if bok == false {
  150. err = os.Remove(file)
  151. s.log.Debugln("ScanPlayedVideoSubInfo.Clear Sub Remove", file)
  152. if err != nil {
  153. s.log.Debugln("ScanPlayedVideoSubInfo.Clear Sub Remove", file, err)
  154. continue
  155. }
  156. }
  157. }
  158. }
  159. func (s *ScanPlayedVideoSubInfo) Scan() error {
  160. // 清空缓存
  161. s.imdbInfoCache = make(map[string]*models.IMDBInfo)
  162. // -----------------------------------------------------
  163. // 并发控制
  164. s.taskControl.SetCtxProcessFunc("ScanSubPlayedPool", s.scan, common.ScanPlayedSubTimeOut)
  165. // -----------------------------------------------------
  166. err := s.taskControl.Invoke(&task_control.TaskData{
  167. Index: 0,
  168. Count: len(s.movieSubMap),
  169. DataEx: ScanInputData{
  170. Videos: s.movieSubMap,
  171. IsMovie: true,
  172. },
  173. })
  174. if err != nil {
  175. s.log.Errorln("ScanPlayedVideoSubInfo.Movie Sub Error", err)
  176. }
  177. err = s.taskControl.Invoke(&task_control.TaskData{
  178. Index: 0,
  179. Count: len(s.seriesSubMap),
  180. DataEx: ScanInputData{
  181. Videos: s.seriesSubMap,
  182. IsMovie: false,
  183. },
  184. })
  185. if err != nil {
  186. s.log.Errorln("ScanPlayedVideoSubInfo.Series Sub Error", err)
  187. }
  188. s.taskControl.Hold()
  189. // 下面需要把给出外部的 HTTP API 提交的视频和字幕信息(ThirdPartSetVideoPlayedInfo)进行判断,存入数据库
  190. shareRootDir, err := my_folder.GetShareSubRootFolder()
  191. if err != nil {
  192. return err
  193. }
  194. var videoPlayedInfos []models.ThirdPartSetVideoPlayedInfo
  195. dao.GetDb().Find(&videoPlayedInfos)
  196. for i, thirdPartSetVideoPlayedInfo := range videoPlayedInfos {
  197. // 先要判断这个是 Movie 还是 Series
  198. // 因为设计这个 API 的时候为了简化提交的参数,也假定传入的可能不是正确的分类(电影or连续剧)
  199. // 所以只能比较傻的,低效率的匹配映射的目录来做到识别是哪个分类的
  200. bFoundMovie := false
  201. bFoundSeries := false
  202. for _, moviePath := range s.settings.CommonSettings.MoviePaths {
  203. // 先判断类型是否是 Movie
  204. if strings.HasPrefix(thirdPartSetVideoPlayedInfo.PhysicalVideoFileFullPath, moviePath) == true {
  205. bFoundMovie = true
  206. break
  207. }
  208. }
  209. if bFoundMovie == false {
  210. for _, seriesPath := range s.settings.CommonSettings.SeriesPaths {
  211. // 判断是否是 Series
  212. if strings.HasPrefix(thirdPartSetVideoPlayedInfo.PhysicalVideoFileFullPath, seriesPath) == true {
  213. bFoundSeries = true
  214. break
  215. }
  216. }
  217. }
  218. if bFoundMovie == false && bFoundSeries == false {
  219. // 说明提交的这个视频文件无法匹配电影或者连续剧的目录前缀
  220. s.log.Warningln("Not matched Movie and Series Prefix Path", thirdPartSetVideoPlayedInfo.PhysicalVideoFileFullPath)
  221. continue
  222. }
  223. IsMovie := false
  224. videoTypes := common.Movie
  225. if bFoundMovie == true {
  226. videoTypes = common.Movie
  227. IsMovie = true
  228. }
  229. if bFoundSeries == true {
  230. videoTypes = common.Series
  231. IsMovie = false
  232. }
  233. tmpSubFPath := filepath.Join(filepath.Dir(thirdPartSetVideoPlayedInfo.PhysicalVideoFileFullPath), thirdPartSetVideoPlayedInfo.SubName)
  234. s.dealOneVideo(i, thirdPartSetVideoPlayedInfo.PhysicalVideoFileFullPath, tmpSubFPath, videoTypes.String(), shareRootDir, IsMovie, s.imdbInfoCache)
  235. }
  236. return nil
  237. }
  238. func (s *ScanPlayedVideoSubInfo) scan(ctx context.Context, inData interface{}) error {
  239. taskData := inData.(*task_control.TaskData)
  240. scanInputData := taskData.DataEx.(ScanInputData)
  241. videoTypes := ""
  242. if scanInputData.IsMovie == true {
  243. videoTypes = "Movie"
  244. } else {
  245. videoTypes = "Series"
  246. }
  247. defer func() {
  248. s.log.Infoln("ScanPlayedVideoSubInfo", videoTypes, "Sub End")
  249. s.log.Infoln("-----------------------------------------------")
  250. }()
  251. s.log.Infoln("-----------------------------------------------")
  252. s.log.Infoln("ScanPlayedVideoSubInfo", videoTypes, "Sub Start...")
  253. shareRootDir, err := my_folder.GetShareSubRootFolder()
  254. if err != nil {
  255. return err
  256. }
  257. index := 0
  258. for videoFPath, orgSubFPath := range scanInputData.Videos {
  259. index++
  260. stage := make(chan interface{}, 1)
  261. go func() {
  262. defer func() {
  263. close(stage)
  264. }()
  265. s.dealOneVideo(index, videoFPath, orgSubFPath, videoTypes, shareRootDir, scanInputData.IsMovie, s.imdbInfoCache)
  266. stage <- 1
  267. }()
  268. select {
  269. case <-ctx.Done():
  270. {
  271. return errors.New(fmt.Sprintf("cancel at scan: %s", videoFPath))
  272. }
  273. case <-stage:
  274. break
  275. }
  276. }
  277. return nil
  278. }
  279. func (s *ScanPlayedVideoSubInfo) dealOneVideo(index int, videoFPath, orgSubFPath, videoTypes, shareRootDir string,
  280. isMovie bool,
  281. imdbInfoCache map[string]*models.IMDBInfo) {
  282. s.log.Infoln(index, orgSubFPath)
  283. if my_util.IsFile(orgSubFPath) == false {
  284. s.log.Errorln("Skip", orgSubFPath, "not exist")
  285. return
  286. }
  287. s.log.Debugln(0)
  288. // 通过视频的绝对路径,从本地的视频文件对应的 nfo 获取到这个视频的 IMDB ID,
  289. var err error
  290. var imdbInfo4Video types.VideoIMDBInfo
  291. if isMovie == true {
  292. imdbInfo4Video, err = decode.GetImdbInfo4Movie(videoFPath)
  293. } else {
  294. imdbInfo4Video, err = decode.GetSeriesSeasonImdbInfoFromEpisode(videoFPath)
  295. }
  296. if err != nil {
  297. // 如果找不到当前电影的 IMDB Info 本地文件,那么就跳过
  298. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".GetImdbInfo", videoFPath, err)
  299. return
  300. }
  301. s.log.Debugln(1)
  302. // 使用本程序的 hash 的算法,得到视频的唯一 ID
  303. fileHash, err := sub_file_hash.Calculate(videoFPath)
  304. if err != nil {
  305. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".ComputeFileHash", videoFPath, err)
  306. return
  307. }
  308. s.log.Debugln(2)
  309. var imdbInfo *models.IMDBInfo
  310. var ok bool
  311. // 先把 IMDB 信息查询查来,不管是从数据库还是网络(查询出来也得写入到数据库)
  312. s.cacheImdbInfoCacheLocker.Lock()
  313. imdbInfo, ok = imdbInfoCache[imdbInfo4Video.ImdbId]
  314. s.cacheImdbInfoCacheLocker.Unlock()
  315. if ok == false {
  316. // 不存在,那么就去查询和新建缓存
  317. imdbInfo, err = imdb_helper.GetVideoIMDBInfoFromLocal(s.log, imdbInfo4Video)
  318. if err != nil {
  319. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".GetVideoIMDBInfoFromLocal", videoFPath, err)
  320. return
  321. }
  322. if len(imdbInfo.Description) <= 0 {
  323. // 需要去外网获去补全信息,然后更新本地的信息
  324. t, err := imdb_helper.GetVideoInfoFromIMDBWeb(imdbInfo4Video, s.settings.AdvancedSettings.ProxySettings)
  325. if err != nil {
  326. s.log.Errorln("dealOneVideo.GetVideoInfoFromIMDBWeb,", imdbInfo4Video.Title, err)
  327. return
  328. }
  329. imdbInfo.Year = t.Year
  330. imdbInfo.AKA = t.AKA
  331. imdbInfo.Description = t.Description
  332. imdbInfo.Languages = t.Languages
  333. dao.GetDb().Save(imdbInfo)
  334. }
  335. s.cacheImdbInfoCacheLocker.Lock()
  336. imdbInfoCache[imdbInfo4Video.ImdbId] = imdbInfo
  337. s.cacheImdbInfoCacheLocker.Unlock()
  338. }
  339. s.log.Debugln(3)
  340. // 这里需要判断是否已经获取了 TMDB Info,如果没有则需要去获取
  341. if imdbInfo.TmdbId == "" {
  342. s.log.Debugln("3-2")
  343. videoType := "movie"
  344. if imdbInfo.IsMovie == false {
  345. videoType = "series"
  346. }
  347. _, err = mix_media_info.GetMediaInfoAndSave(
  348. s.log,
  349. s.fileDownloader.SubtitleBestApi,
  350. imdbInfo,
  351. imdbInfo.IMDBID, "imdb", videoType)
  352. if err != nil {
  353. s.log.Errorln("dealOneVideo.GetMediaInfoAndSave,", imdbInfo.Name, err)
  354. return
  355. }
  356. }
  357. s.log.Debugln("3-2")
  358. // 当前扫描到的找个字幕的 sha256 是否已经存在与缓存中了
  359. tmpSHA256String, err := my_util.GetFileSHA256String(orgSubFPath)
  360. if err != nil {
  361. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, "orgSubFPath.GetFileSHA256String", videoFPath, err)
  362. return
  363. }
  364. s.log.Debugln(4)
  365. // 判断找到的关联字幕信息是否已经存在了,不存在则新增关联
  366. for _, cacheInfo := range imdbInfo.VideoSubInfos {
  367. if cacheInfo.SHA256 == tmpSHA256String {
  368. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, "SHA256 Exist == true, Skip", orgSubFPath)
  369. return
  370. }
  371. }
  372. s.log.Debugln(5)
  373. // 新增插入
  374. // 把现有的字幕 copy 到缓存目录中
  375. bok, subCacheFPath := sub_share_center.CopySub2Cache(s.log, orgSubFPath, imdbInfo.IMDBID, imdbInfo.Year)
  376. if bok == false {
  377. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".CopySub2Cache", orgSubFPath, err)
  378. return
  379. }
  380. s.log.Debugln(6)
  381. // 不存在,插入,建立关系
  382. bok, fileInfo, err := s.subParserHub.DetermineFileTypeFromFile(subCacheFPath)
  383. if err != nil {
  384. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".DetermineFileTypeFromFile", imdbInfo4Video.ImdbId, err)
  385. return
  386. }
  387. if bok == false {
  388. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".DetermineFileTypeFromFile == false", imdbInfo4Video.ImdbId)
  389. return
  390. }
  391. s.log.Debugln(7)
  392. // 特指 emby 字幕的情况
  393. _, _, _, _, extraSubPreName := s.subFormatter.IsMatchThisFormat(filepath.Base(subCacheFPath))
  394. // 转相对路径存储
  395. subRelPath, err := filepath.Rel(shareRootDir, subCacheFPath)
  396. if err != nil {
  397. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".Rel", imdbInfo4Video.ImdbId, err)
  398. return
  399. }
  400. s.log.Debugln(8)
  401. // 计算需要插入字幕的 sha256
  402. saveSHA256String, err := my_util.GetFileSHA256String(subCacheFPath)
  403. if err != nil {
  404. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, "GetFileSHA256String", videoFPath, err)
  405. return
  406. }
  407. s.log.Debugln(9)
  408. oneVideoSubInfo := models.NewVideoSubInfo(
  409. fileHash,
  410. filepath.Base(subCacheFPath),
  411. language.MyLang2ISO_639_1_String(fileInfo.Lang),
  412. language.IsBilingualSubtitle(fileInfo.Lang),
  413. language.MyLang2ChineseISO(fileInfo.Lang),
  414. fileInfo.Lang.String(),
  415. subRelPath,
  416. extraSubPreName,
  417. saveSHA256String,
  418. )
  419. if isMovie == false {
  420. // 连续剧的时候,如果可能应该获取是 第几季 第几集
  421. torrentInfo, _, err := decode.GetVideoInfoFromFileFullPath(videoFPath)
  422. if err != nil {
  423. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".GetVideoInfoFromFileFullPath", imdbInfo4Video.Title, err)
  424. return
  425. }
  426. oneVideoSubInfo.Season = torrentInfo.Season
  427. oneVideoSubInfo.Episode = torrentInfo.Episode
  428. }
  429. s.log.Debugln(10)
  430. err = dao.GetDb().Model(imdbInfo).Association("VideoSubInfos").Append(oneVideoSubInfo)
  431. if err != nil {
  432. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", videoTypes, ".Append Association", oneVideoSubInfo.SubName, err)
  433. return
  434. }
  435. }
  436. // 如果文件不存在,那么就删除之前的关联
  437. // 关联删除了,但是不会删除这些对象,所以后续还需要再次删除
  438. func (s *ScanPlayedVideoSubInfo) delSubInfo(imdbInfo *models.IMDBInfo, cacheInfo *models.VideoSubInfo) bool {
  439. err := dao.GetDb().Model(imdbInfo).Association("VideoSubInfos").Delete(cacheInfo)
  440. if err != nil {
  441. s.log.Warningln("ScanPlayedVideoSubInfo.Scan", ".Delete Association", cacheInfo.SubName, err)
  442. return false
  443. }
  444. // 继续删除这个对象
  445. dao.GetDb().Delete(cacheInfo)
  446. s.log.Infoln("Delete Not Exist or SHA256 not the same, Sub Association", cacheInfo.SubName)
  447. return true
  448. }
  449. type ScanInputData struct {
  450. Videos map[string]string
  451. IsMovie bool
  452. }