· 9 years ago · Mar 01, 2017, 04:28 PM
1#!/usr/bin/env filebot -script
2
3
4// log input parameters
5log.fine("Run script [$_args.script] at [$now]")
6_def.each{ n, v -> log.finest('Parameter: ' + [n, n =~ /plex|kodi|pushover|pushbullet|mail|myepisodes/ ? '*****' : v].join(' = ')) }
7args.withIndex().each{ f, i -> if (f.exists()) { log.finest "Argument[$i]: $f" } else { log.warning "Argument[$i]: File does not exist: $f" } }
8
9
10// initialize variables
11failOnError = _args.conflict.equalsIgnoreCase('fail')
12testRun = _args.action.equalsIgnoreCase('test')
13
14// --output folder must be a valid folder
15outputFolder = tryLogCatch{ any{ _args.output }{ '.' }.toFile().getCanonicalFile() }
16
17// enable/disable features as specified via --def parameters
18unsorted = tryQuietly{ unsorted.toBoolean() }
19music = tryQuietly{ music.toBoolean() }
20subtitles = tryQuietly{ subtitles.split(/\W+/) as List }
21artwork = tryQuietly{ artwork.toBoolean() && !testRun }
22extras = tryQuietly{ extras.toBoolean() }
23clean = tryQuietly{ clean.toBoolean() }
24exec = tryQuietly{ exec.toString() }
25
26// array of kodi/plex/emby hosts
27kodi = tryQuietly{ any{kodi}{xbmc}.split(/[ ,;|]+/)*.split(/:(?=\d+$)/).collect{ it.length >= 2 ? [host: it[0], port: it[1] as int] : [host: it[0]] } }
28plex = tryQuietly{ plex.split(/[ ,;|]+/)*.split(/:/).collect{ it.length >= 2 ? [host: it[0], token: it[1]] : [host: it[0]] } }
29emby = tryQuietly{ emby.split(/[ ,;|]+/)*.split(/:/).collect{ it.length >= 2 ? [host: it[0], token: it[1]] : [host: it[0]] } }
30
31// extra options, myepisodes updates and email notifications
32extractFolder = tryQuietly{ extractFolder as File }
33skipExtract = tryQuietly{ skipExtract.toBoolean() }
34deleteAfterExtract = tryQuietly{ deleteAfterExtract.toBoolean() }
35excludeList = tryQuietly{ def f = excludeList as File; f.isAbsolute() ? f : outputFolder.resolve(f.path) }
36myepisodes = tryQuietly{ myepisodes.split(':', 2) as List }
37gmail = tryQuietly{ gmail.split(':', 2) as List }
38mail = tryQuietly{ mail.split(':', 5) as List }
39pushover = tryQuietly{ pushover.split(':', 2) as List }
40pushbullet = tryQuietly{ pushbullet.toString() }
41storeReport = tryQuietly{ storeReport.toBoolean() }
42reportError = tryQuietly{ reportError.toBoolean() }
43
44// user-defined filters
45label = any{ _args.mode }{ ut_label }{ null }
46ignore = any{ ignore }{ null }
47minFileSize = any{ minFileSize.toLong() }{ 50 * 1000L * 1000L }
48minLengthMS = any{ minLengthMS.toLong() }{ 10 * 60 * 1000L }
49
50// series/anime/movie format expressions
51seriesFormat = any{ seriesFormat }{ '{plex}' }
52animeFormat = any{ animeFormat }{ '{plex}' }
53movieFormat = any{ movieFormat }{ '{plex}' }
54musicFormat = any{ musicFormat }{ '{plex}' }
55unsortedFormat = any{ unsortedFormat }{ 'Unsorted/{file.structurePathTail}' }
56
57
58
59// force Movie / TV Series / Anime behaviour
60def forceMovie(f) {
61 label =~ /^(?i:Movie|Film|Concert|UFC)/ || f.dir.listPath().any{ it.name ==~ /(?i:Movies|Movie)/ } || f.isMovie()
62}
63
64def forceSeries(f) {
65 label =~ /^(?i:TV|Show|Series|Documentary)/ || f.dir.listPath().any{ it.name ==~ /(?i:TV.Shows|TV.Series)/ } || f.path =~ /(?<=\b|_)(?i:tv[sp]-|Season\D?\d{1,2}|\d{4}.S\d{2})(?=\b|_)/ || parseEpisodeNumber(f.path, true) || parseDate(f.path)
66}
67
68def forceAnime(f) {
69 label =~ /^(?i:Anime)/ || f.dir.listPath().any{ it.name ==~ /(?i:Anime)/ } || (f.isVideo() && (f.name =~ /(?i:HorribleSubs)/ || f.name =~ /[\(\[]\p{XDigit}{8}[\]\)]/ || any{ getMediaInfo(f, '{media.AudioLanguageList} {media.TextCodecList}').tokenize().containsAll(['Japanese', 'ASS']) && (parseEpisodeNumber(f.name, false) != null || getMediaInfo(f, '{minutes}').toInteger() < 60) }{ false }))
70}
71
72def forceAudio(f) {
73 label =~ /^(?i:audio|music|music.video)/ || (f.isAudio() && !f.isVideo())
74}
75
76def forceIgnore(f) {
77 label =~ /^(?i:games|ebook|other|ignore)/
78}
79
80
81
82// include artwork/nfo, pushover/pushbullet and ant utilities as required
83if (artwork || kodi || plex || emby) { include('lib/htpc') }
84if (pushover || pushbullet ) { include('lib/web') }
85if (gmail || mail) { include('lib/ant') }
86
87
88
89// error reporting functions
90def sendEmailReport(title, message, messagetype) {
91 if (gmail) {
92 sendGmail(
93 subject: title, message: message, messagemimetype: messagetype,
94 to: any{ mailto } { gmail[0].contains('@') ? gmail[0] : gmail[0] + '@gmail.com' }, // mail to self by default
95 user: gmail[0].contains('@') ? gmail[0] : gmail[0] + '@gmail.com', password: gmail[1]
96 )
97 }
98 if (mail) {
99 sendmail(
100 subject: title, message: message, messagemimetype: messagetype,
101 mailhost: mail[0], mailport: mail[1], from: mail[2], to: mailto,
102 user: mail[3], password: mail[4]
103 )
104 }
105}
106
107def fail(message) {
108 if (reportError) {
109 sendEmailReport('[FileBot] Failure', message as String, 'text/plain')
110 }
111 die(message)
112}
113
114
115
116// check input parameters
117def ut = _def.findAll{ k, v -> k.startsWith('ut_') }.collectEntries{ k, v ->
118 if (v ==~ /[%$]\p{Alnum}|\p{Punct}+/) {
119 log.warning "Bad $k value: $v"
120 v = null
121 }
122 return [k.substring(3), v ? v : null]
123}
124
125
126
127// sanity checks
128if (outputFolder == null || !outputFolder.isDirectory()) {
129 fail "Illegal usage: output folder must exist and must be a directory: $outputFolder"
130}
131
132if (ut.dir) {
133 if (ut.state_allow && !(ut.state ==~ ut.state_allow)) {
134 fail "Illegal state: $ut.state != $ut.state_allow"
135 }
136 if (args.size() > 0) {
137 fail "Illegal usage: use either script parameters $ut or file arguments $args but not both"
138 }
139 if (ut.dir == '/') {
140 fail "Illegal usage: No! Are you insane? You can't just pass in the entire filesystem. Think long and hard about what you just tried to do."
141 }
142 if (outputFolder.path.startsWith(ut.dir)) {
143 fail "Illegal usage: output folder [$outputFolder] must be separate from input folder $ut"
144 }
145} else if (args.size() == 0) {
146 fail "Illegal usage: no input"
147} else if (args.any{ f -> outputFolder.path.startsWith(f.path) }) {
148 fail "Illegal usage: output folder [$outputFolder] must be separate from input arguments $args"
149} else if (args.any{ f -> f in File.listRoots() }) {
150 fail "Illegal usage: input $args must not include a filesystem root"
151}
152
153
154
155// collect input fileset as specified by the given --def parameters
156roots = args
157
158if (args.size() == 0) {
159 // assume we're called with utorrent parameters (account for older and newer versions of uTorrents)
160 if (ut.kind == 'single' || (ut.kind != 'multi' && ut.dir && ut.file)) {
161 roots = [new File(ut.dir, ut.file).getCanonicalFile()] // single-file torrent
162 } else {
163 roots = [new File(ut.dir).getCanonicalFile()] // multi-file torrent
164 }
165}
166
167// helper function to work with the structure relative path rather than the whole absolute path
168def relativeInputPath(f) {
169 def r = roots.find{ r -> f.path.startsWith(r.path) && r.isDirectory() && f.isFile() }
170 if (r != null) {
171 return f.path.substring(r.path.length() + 1)
172 }
173 return f.name
174}
175
176
177
178// define and load exclude list (e.g. to make sure files are only processed once)
179excludePathSet = new FileSet()
180
181if (excludeList) {
182 if (excludeList.exists()) {
183 try {
184 excludePathSet.load(excludeList)
185 } catch(Exception e) {
186 fail "Failed to load excludeList: $e"
187 }
188 log.fine "Use excludes: $excludeList (${excludePathSet.size()})"
189 } else {
190 log.fine "Use excludes: $excludeList"
191 if ((!excludeList.parentFile.isDirectory() && !excludeList.parentFile.mkdirs()) || (!excludeList.isFile() && !excludeList.createNewFile())) {
192 fail "Failed to create excludeList: $excludeList"
193 }
194 }
195}
196
197
198extractedArchives = []
199temporaryFiles = []
200
201def extract(f) {
202 def folder = new File(extractFolder ?: f.dir, f.nameWithoutExtension)
203 def files = extract(file: f, output: folder.resolve(f.dir.name), conflict: 'auto', filter: { it.isArchive() || it.isVideo() || it.isSubtitle() || (music && it.isAudio()) }, forceExtractAll: true) ?: []
204
205 extractedArchives += f
206 temporaryFiles += folder
207 temporaryFiles += files
208
209 return files
210}
211
212
213def acceptFile(f) {
214 if (f.isHidden()) {
215 log.finest "Ignore hidden: $f"
216 return false
217 }
218
219 if (f.isDirectory() && f.name ==~ /[.@].+|bin|initrd|opt|sbin|var|dev|lib|proc|sys|var.defaults|etc|lost.found|root|tmp|etc.defaults|mnt|run|usr|System.Volume.Information/) {
220 log.finest "Ignore system path: $f"
221 return false
222 }
223
224 if (f.name =~ /(?<=\b|_)(?i:Sample|Trailer|Extras|Extra.Episodes|Bonus.Features|Music.Video|Scrapbook|Behind.the.Scenes|Extended.Scenes|Deleted.Scenes|Mini.Series|s\d{2}c\d{2}|S\d+EXTRA|\d+xEXTRA|NCED|NCOP|(OP|ED)\d+|Formula.1.\d{4})(?=\b|_)/) {
225 log.finest "Ignore extra: $f"
226 return false
227 }
228
229 // ignore if the user-defined ignore pattern matches
230 if (f.path.findMatch(ignore)) {
231 log.finest "Ignore pattern: $f"
232 return false
233 }
234
235 // ignore archives that are on the exclude path list
236 if (excludePathSet.contains(f)) {
237 return false
238 }
239
240 // accept folders right away and skip file sanity checks
241 if (f.isDirectory()) {
242 return true
243 }
244
245 // accept archives if the extract feature is enabled
246 if (f.isArchive() || f.hasExtension('001')) {
247 return !skipExtract
248 }
249
250 // ignore iso images that do not contain a video disk structure
251 if (f.hasExtension('iso') && !f.isDisk()) {
252 log.fine "Ignore disk image: $f"
253 return false
254 }
255
256 // ignore small video files
257 if (minFileSize > 0 && f.isVideo() && f.length() < minFileSize) {
258 log.fine "Skip small video file: $f"
259 return false
260 }
261
262 // ignore short videos
263 if (minLengthMS > 0 && f.isVideo() && any{ getMediaInfo(f, '{duration}').toLong() < minLengthMS }{ false /* default if MediaInfo fails */ }) {
264 log.fine "Skip short video: $f"
265 return false
266 }
267
268 // ignore subtitle files without matching video file in the same or parent folder
269 if (f.isSubtitle() && ![f, f.dir].findResults{ it.dir }.any{ it.listFiles{ it.isVideo() && f.isDerived(it) }}) {
270 log.fine "Ignore orphaned subtitles: $f"
271 return false
272 }
273
274 // process only media files (accept audio files only if music mode is enabled)
275 return f.isVideo() || f.isSubtitle() || (music && f.isAudio())
276}
277
278
279// specify how to resolve input folders, e.g. grab files from all folders except disk folders and already processed folders (i.e. folders with movie/tvshow nfo files)
280def resolveInput(f) {
281 // resolve folder recursively, except disk folders
282 if (f.isDirectory()) {
283 if (f.isDisk()) {
284 return f
285 }
286 return f.listFiles{ acceptFile(it) }.collect{ resolveInput(it) }
287 }
288
289 if (f.isArchive() || f.hasExtension('001')) {
290 return extract(f).findAll{ acceptFile(it) }.collect{ resolveInput(it) }
291 }
292
293 return f
294}
295
296
297
298// flatten nested file structure
299def input = roots.findAll{ acceptFile(it) }.flatten{ resolveInput(it) }.toSorted()
300
301// update exclude list with all input that will be processed during this run
302if (excludeList && !testRun) {
303 excludePathSet.append(excludeList, extractedArchives, input)
304}
305
306// print exclude and input sets for logging
307input.each{ log.fine "Input: $it" }
308
309// early abort if there is nothing to do
310if (input.size() == 0) {
311 log.warning "No files selected for processing"
312 return
313}
314
315
316
317// group episodes/movies and rename according to Plex standards
318def groups = input.groupBy{ f ->
319 // print xattr metadata
320 if (f.metadata) {
321 log.finest "xattr: [$f.name] => [$f.metadata]"
322 }
323
324 // skip auto-detection if possible
325 if (forceIgnore(f))
326 return []
327 if (music && forceAudio(f)) // process audio only if music mode is enabled
328 return [music: f.dir.name]
329 if (forceMovie(f))
330 return [mov: detectMovie(f, false)]
331 if (forceSeries(f))
332 return [tvs: detectSeriesName(f) ?: detectSeriesName(input.findAll{ s -> f.dir == s.dir && s.isVideo() })]
333 if (forceAnime(f))
334 return [anime: detectAnimeName(f) ?: detectAnimeName(input.findAll{ s -> f.dir == s.dir && s.isVideo() })]
335
336
337 def tvs = detectSeriesName(f)
338 def mov = detectMovie(f, false)
339 log.fine "$f.name [series: $tvs, movie: $mov]"
340
341 // DECIDE EPISODE VS MOVIE (IF NOT CLEAR)
342 if (tvs && mov) {
343 def norm = { s -> s.ascii().normalizePunctuation().lower().space(' ') }
344 def dn = norm(guessMovieFolder(f)?.name ?: '')
345 def fn = norm(f.nameWithoutExtension)
346 def sn = norm(tvs)
347 def mn = norm(mov.name)
348 def my = mov.year as String
349
350 // S00E00 | 2012.07.21 | One Piece 217 | Firefly - Serenity | [Taken 1, Taken 2, Taken 3, Taken 4, ..., Taken 10]
351 def metrics = [
352 [tvs: -1, mov: 0, fun: { mn == fn } ],
353 [tvs: -1, mov: 0, fun: { mov.year >= 1950 && f.listPath().reverse().take(3).find{ it.name.contains(my) && parseEpisodeNumber(it.name.after(my), false) == null } } ],
354 [tvs: -1, mov: 0, fun: { mn =~ sn && [dn, fn].find{ it =~ /\b(19|20)\d{2}\b/ && parseEpisodeNumber(it.after(/\b(19|20)\d{2}\b/), false) == null } } ],
355 [tvs: 5, mov: -1, fun: { parseEpisodeNumber(fn, true) || parseDate(fn) } ],
356 [tvs: 5, mov: -1, fun: { f.dir.listFiles{ it.isVideo() && (dn =~ sn || norm(it.name) =~ sn) && it.name =~ /\d{1,3}/}.findResults{ it.name.matchAll(/\d{1,3}/) as Set }.unique().size() >= 10 } ],
357 [tvs: 1, mov: -1, fun: { fn.after(sn) ==~ /.{0,3}\s-\s.+/ && matchMovie(fn) == null } ],
358 [tvs: 1, mov: -1, fun: { [dn, fn].find{ it =~ sn && matchMovie(it) == null } && (parseEpisodeNumber(stripReleaseInfo(fn.after(sn), false), false) || stripReleaseInfo(fn.after(sn), false) =~ /\D\d{1,2}\D{1,3}\d{1,2}\D/) && matchMovie(fn) == null } ],
359 [tvs: -1, mov: 1, fun: { fn =~ /tt\d{7}/ } ],
360 [tvs: -1, mov: 1, fun: { f.nameWithoutExtension ==~ /[\D\s_.]+/ } ],
361 [tvs: -1, mov: 5, fun: { detectMovie(f, true) && [dn, fn].find{ it =~ /(19|20)\d{2}/ } != null } ],
362 [tvs: -1, mov: 1, fun: { fn.contains(mn) && parseEpisodeNumber(fn.after(mn), false) == null } ],
363 [tvs: -1, mov: 1, fun: { mn.getSimilarity(fn) >= 0.8 || [dn, fn].find{ it.findAll( ~/\d{4}/ ).findAll{ y -> [mov.year-1, mov.year, mov.year+1].contains(y.toInteger()) }.size() > 0 } != null } ],
364 [tvs: -1, mov: 1, fun: { [dn, fn].find{ it =~ mn && !(it.after(mn) =~ /\b\d{1,3}\b/) && (it.getSimilarity(mn) > 0.2 + it.getSimilarity(sn)) } != null } ],
365 [tvs: -1, mov: 1, fun: { detectMovie(f, false).aliasNames.find{ fn.contains(norm(it)) } } ]
366 ]
367
368 def score = [tvs: 0, mov: 0]
369 metrics.each{
370 if (tvs && mov && it.fun()) {
371 score.tvs += it.tvs
372 score.mov += it.mov
373
374 if (score.tvs >= 1 && score.mov <= -1) {
375 log.fine "Exclude Movie: $mov"
376 mov = null
377 } else if (score.mov >= 1 && score.tvs <= -1) {
378 log.fine "Exclude Series: $tvs"
379 tvs = null
380 }
381 }
382 }
383
384 }
385
386 // CHECK CONFLICT
387 if (((mov && tvs) || (!mov && !tvs))) {
388 if (failOnError) {
389 fail 'Media detection failed'
390 } else {
391 log.fine "Unable to differentiate: [$f.name] => [$tvs] VS [$mov]"
392 return [:]
393 }
394 }
395
396 return [tvs: tvs, mov: mov]
397}
398
399// group entries by unique tvs/mov descriptor
400groups = groups.groupBy{ group, files -> group.collectEntries{ type, query -> [type, query ? query.toString().ascii().normalizePunctuation().lower() : null] } }.collectEntries{ group, maps -> [group, maps.values().flatten()] }
401
402// log movie/series/anime detection results
403groups.each{ group, files -> log.finest "Group: $group => ${files*.name}" }
404
405// keep track of unsorted files or files that could not be processed for some reason
406def unsortedFiles = []
407
408// process each batch
409groups.each{ group, files ->
410 // fetch subtitles (but not for anime)
411 if (group.anime == null && subtitles != null && files.findAll{ it.isVideo() }.size() > 0) {
412 subtitles.each{ languageCode ->
413 def subtitleFiles = getMissingSubtitles(file: files, lang: languageCode, strict: true, output: 'srt', encoding: 'UTF-8', format: 'MATCH_VIDEO_ADD_LANGUAGE_TAG') ?: []
414 files += subtitleFiles
415 input += subtitleFiles // make sure subtitles are added to the exclude list and other post processing operations
416 temporaryFiles += subtitleFiles // if downloaded for temporarily extraced files delete later
417 }
418 }
419
420 // EPISODE MODE
421 if ((group.tvs || group.anime) && !group.mov) {
422 // choose series / anime
423 def dest = group.tvs ? rename(file: files, format: seriesFormat, db: 'TheTVDB') : rename(file: files, format: animeFormat, db: 'AniDB')
424
425 if (dest != null) {
426 if (artwork) {
427 dest.mapByFolder().each{ dir, fs ->
428 def hasSeasonFolder = any{ dir =~ /Specials|Season.\d+/ || dir.parentFile.structurePathTail.listPath().size() > 0 }{ false } // MAY NOT WORK FOR CERTAIN FORMATS
429
430 fs.findResults{ it.metadata }.findAll{ it.seriesInfo.database == 'TheTVDB' }.collect{ [name: it.seriesName, season: it.special ? 0 : it.season, id: it.seriesInfo.id] }.unique().each{
431 log.fine "Fetching series artwork for [$it.name / Season $it.season] to [$dir]"
432 fetchSeriesArtworkAndNfo(hasSeasonFolder ? dir.parentFile : dir, dir, it.id, it.season, false, _args.language.locale)
433 }
434 }
435 }
436 } else if (failOnError) {
437 fail "Failed to process group: $group"
438 } else {
439 unsortedFiles += files
440 }
441 }
442
443 // MOVIE MODE
444 else if (group.mov && !group.tvs && !group.anime) {
445 def dest = rename(file: files, format: movieFormat, db: 'TheMovieDB')
446
447 if (dest != null) {
448 if (artwork) {
449 dest.mapByFolder().each{ dir, fs ->
450 def movieFile = fs.findAll{ it.isVideo() || it.isDisk() }.sort{ it.length() }.reverse().findResult{ it }
451 if (movieFile) {
452 def movieInfo = movieFile.metadata
453 log.fine "Fetching movie artwork for [$movieInfo] to [$dir]"
454 fetchMovieArtworkAndNfo(dir, movieInfo, movieFile, extras, false, _args.language.locale)
455 }
456 }
457 }
458 } else if (failOnError) {
459 fail "Failed to process group: $group"
460 } else {
461 unsortedFiles += files
462 }
463 }
464
465 // MUSIC MODE
466 else if (group.music) {
467 def dest = rename(file: files, format: musicFormat, db: 'ID3')
468
469 if (dest != null) {
470 // music artwork not supported
471 } else if (failOnError) {
472 fail "Failed to process group: $group"
473 } else {
474 unsortedFiles += files
475 }
476 }
477
478 // UNSORTED
479 else {
480 unsortedFiles += files
481 }
482}
483
484
485// ---------- POST PROCESSING ---------- //
486
487// deal with remaining files that cannot be sorted automatically
488if (unsorted) {
489 if (unsortedFiles.size() > 0) {
490 log.fine "Processing ${unsortedFiles.size()} unsorted files"
491 rename(map: unsortedFiles.collectEntries{ original ->
492 def destination = getMediaInfo(original, unsortedFormat) as File
493
494 // sanity check user-defined unsorted format
495 if (destination == null) {
496 fail("Illegal usage: unsorted format must yield valid file path")
497 }
498
499 // resolve relative paths
500 if (!destination.isAbsolute()) {
501 destination = outputFolder.resolve(destination.path)
502 }
503
504 return [original, destination]
505 })
506 }
507}
508
509// run program on newly processed files
510if (exec) {
511 getRenameLog().collect{ from, to -> getMediaInfo(to, exec) }.unique().each{ command ->
512 log.fine "Execute: $command"
513 execute(command)
514 }
515}
516
517
518// ---------- REPORTING ---------- //
519
520
521if (getRenameLog().size() > 0) {
522 // messages used for kodi / plex / emby pushover notifications
523 def getNotificationTitle = {
524 def count = getRenameLog().count{ k, v -> !v.isSubtitle() }
525 return "FileBot finished processing $count files"
526 }.memoize()
527
528 def getNotificationMessage = { prefix = '• ', postfix = '\n' ->
529 return ut.title ?: (input.findAll{ !it.isSubtitle() } ?: input).collect{ relativeInputPath(it) as File }.root.nameWithoutExtension.unique().collect{ prefix + it }.join(postfix).trim()
530 }.memoize()
531
532 // make Kodi scan for new content and display notification message
533 if (kodi) {
534 kodi.each{ instance ->
535 log.fine "Notify Kodi: $instance"
536 tryLogCatch {
537 showNotification(instance.host, instance.port ?: 8080, getNotificationTitle(), getNotificationMessage(), 'http://app.filebot.net/icon.png')
538 scanVideoLibrary(instance.host, instance.port ?: 8080)
539 }
540 }
541 }
542
543 // make Plex scan for new content
544 if (plex) {
545 plex.each{ instance ->
546 log.fine "Notify Plex: $instance"
547 tryLogCatch {
548 refreshPlexLibrary(instance.host, 32400, instance.token)
549 }
550 }
551 }
552
553 // make Emby scan for new content
554 if (emby) {
555 emby.each{ instance ->
556 log.fine "Notify Emby: $instance"
557 tryLogCatch {
558 refreshEmbyLibrary(instance.host, 8096, instance.token)
559 }
560 }
561 }
562
563 // mark episodes as 'acquired'
564 if (myepisodes) {
565 log.fine 'Update MyEpisodes'
566 tryLogCatch {
567 executeScript('update-mes', [login:myepisodes.join(':'), addshows:true], getRenameLog().values())
568 }
569 }
570
571 if (pushover) {
572 log.fine 'Sending Pushover notification'
573 tryLogCatch {
574 Pushover(pushover[0], pushover[1] ?: 'wcckDz3oygHSU2SdIptvnHxJ92SQKK').send(getNotificationTitle(), getNotificationMessage())
575 }
576 }
577
578 // messages used for email / pushbullet reports
579 def getReportSubject = { getNotificationMessage('', ' | ') }
580 def getReportTitle = { '[FileBot] ' + getReportSubject() }
581 def getReportMessage = {
582 def renameLog = getRenameLog()
583 '''<!DOCTYPE html>\n''' + XML {
584 html {
585 head {
586 meta(charset:'UTF-8')
587 style('''
588 p{font-family:Arial,Helvetica,sans-serif}
589 p b{color:#07a}
590 hr{border-style:dashed;border-width:1px 0 0 0;border-color:lightgray}
591 small{color:#d3d3d3;font-size:xx-small;font-weight:normal;font-family:Arial,Helvetica,sans-serif}
592 table a:link{color:#666;font-weight:bold;text-decoration:none}
593 table a:visited{color:#999;font-weight:bold;text-decoration:none}
594 table a:active,table a:hover{color:#bd5a35;text-decoration:underline}
595 table{font-family:Arial,Helvetica,sans-serif;color:#666;background:#eaebec;margin:15px;border:#ccc 1px solid;border-radius:3px;box-shadow:0 1px 2px #d1d1d1}
596 table th{padding:15px;border-top:1px solid #fafafa;border-bottom:1px solid #e0e0e0;background:#ededed}
597 table th{text-align:center;padding-left:20px}
598 table tr:first-child th:first-child{border-top-left-radius:3px}
599 table tr:first-child th:last-child{border-top-right-radius:3px}
600 table tr{text-align:left;padding-left:20px}
601 table td:first-child{text-align:left;padding-left:20px;border-left:0}
602 table td{padding:15px;border-top:1px solid #fff;border-bottom:1px solid #e0e0e0;border-left:1px solid #e0e0e0;background:#fafafa;white-space:nowrap}
603 table tr.even td{background:#f6f6f6}
604 table tr:last-child td{border-bottom:0}
605 table tr:last-child td:first-child{border-bottom-left-radius:3px}
606 table tr:last-child td:last-child{border-bottom-right-radius:3px}
607 table tr:hover td{background:#f2f2f2}
608 ''')
609 title(getReportTitle())
610 }
611 body {
612 p {
613 mkp.yield("FileBot finished processing ")
614 b(getReportSubject())
615 mkp.yield(" (${renameLog.size()} files).")
616 }
617 hr(); table {
618 tr { th('Original Name'); th('New Name'); th('New Location') }
619 renameLog.each{ from, to ->
620 tr { [from.name, to.name, to.parent].each{ cell -> td(cell) } }
621 }
622 }
623 hr(); small("// Generated by ${Settings.applicationIdentifier} on ${InetAddress.localHost.hostName} at ${now}")
624 }
625 }
626 }
627 }
628
629 // store processing report
630 if (storeReport) {
631 def reportFolder = ApplicationFolder.AppData.resolve('reports')
632 def reportName = [now.format(/[yyyy-MM-dd HH mm]/), getReportSubject().take(50)].join(' ').validateFileName().space('_')
633 def reportFile = getReportMessage().saveAs(reportFolder.resolve(reportName + '.html'))
634 log.finest "Saving report as ${reportFile}"
635 }
636
637 // send pushbullet report
638 if (pushbullet) {
639 log.fine 'Sending PushBullet report'
640 tryLogCatch {
641 PushBullet(pushbullet).sendFile(getNotificationTitle(), getReportMessage(), 'text/html', getNotificationMessage(), any{ mailto }{ null })
642 }
643 }
644
645 // send email report
646 if (gmail || mail) {
647 tryLogCatch {
648 sendEmailReport(getReportTitle(), getReportMessage(), 'text/html')
649 }
650 }
651}
652
653
654// ---------- CLEAN UP ---------- //
655
656
657// clean up temporary files that may be left behind after extraction
658if (deleteAfterExtract) {
659 extractedArchives.each{ a ->
660 log.finest "Delete archive $a"
661 a.delete()
662 a.dir.listFiles().toList().findAll{ v -> v.name.startsWith(a.nameWithoutExtension) && v.extension ==~ /r\d+/ }.each{ v ->
663 log.finest "Delete archive volume $v"
664 v.delete()
665 }
666 }
667}
668
669// clean empty folders, clutter files, etc after move
670if (clean) {
671 if (['DUPLICATE', 'COPY', 'HARDLINK'].any{ it.equalsIgnoreCase(_args.action) } && temporaryFiles.size() > 0) {
672 log.fine 'Clean temporary extracted files'
673 // delete extracted files
674 temporaryFiles.findAll{ it.isFile() }.sort().each{
675 log.finest "Delete $it"
676 it.delete()
677 }
678 // delete remaining empty folders
679 temporaryFiles.findAll{ it.isDirectory() }.sort().reverse().each{
680 log.finest "Delete $it"
681 if (it.getFiles().size() == 0) {
682 it.deleteDir()
683 }
684 }
685 }
686
687 // deleting remaining files only makes sense after moving files
688 if ('MOVE'.equalsIgnoreCase(_args.action)) {
689 def cleanerInput = args.size() > 0 ? args : ut.kind == 'multi' && ut.dir ? [ut.dir as File] : []
690 cleanerInput = cleanerInput.findAll{ f -> f.exists() }
691 if (cleanerInput.size() > 0) {
692 log.fine 'Clean clutter files and empty folders'
693 executeScript('cleaner', args.size() == 0 ? [root:true, ignore: ignore] : [root:false, ignore: ignore], cleanerInput)
694 }
695 }
696}
697
698
699
700if (getRenameLog().size() == 0) {
701 fail "Finished without processing any files"
702}