· 10 years ago · Jul 15, 2016, 01:33 AM
1// filebot -script "fn:amc" --output "X:/media" --action copy --conflict override --def subtitles=en music=y artwork=y "ut_dir=%D" "ut_file=%F" "ut_kind=%K" "ut_title=%N" "ut_label=%L" "ut_state=%S"
2// "C:/Program Files/FileBot/filebot.launcher.exe" -script "I:/Downloads/Links/amc.groovy" --output "I:/Downloads/Links" --log-file "I:/Downloads/Links/amc.log" --action hardlink --conflict override -non-strict --def music=y excludeList="I:/Downloads/Links/amc-input.txt" plex="127.0.0.1" "ut_label=%L" "ut_state=%S" "ut_title=%N" "ut_kind=%K" "ut_file=%F" "ut_dir=%D"
3
4// log input parameters
5log.fine("Run script [${_args.script}] at [${now}]")
6_def.each{ n, v -> log.finer('Parameter: ' + [n, n =~ /pushover|pushbullet|mail|myepisodes/ ? '*****' : v].join(' = ')) }
7args.each{ log.finer("Argument: $it") }
8
9
10// initialize variables
11def input = []
12def failOnError = _args.conflict.equalsIgnoreCase('fail')
13def isTest = _args.action.equalsIgnoreCase('test')
14
15// enable/disable features as specified via --def parameters
16def unsorted = tryQuietly{ unsorted.toBoolean() }
17def music = tryQuietly{ music.toBoolean() }
18def subtitles = tryQuietly{ subtitles.split(/\W+/) as List }
19def artwork = tryQuietly{ artwork.toBoolean() && !isTest }
20def extras = tryQuietly{ extras.toBoolean() }
21def clean = tryQuietly{ clean.toBoolean() }
22def exec = tryQuietly{ exec.toString() }
23
24// array of xbmc/plex hosts
25def xbmc = tryQuietly{ xbmc.split(/[ ,|]+/) }
26def plex = tryQuietly{ plex.split(/[ ,|]+/)*.split(/:/).collect{ it.length >= 2 ? [host:it[0], token:it[1]] : [host:it[0]] } }
27
28// extra options, myepisodes updates and email notifications
29def storeReport = tryQuietly{ storeReport.toBoolean() }
30def skipExtract = tryQuietly{ skipExtract.toBoolean() }
31def deleteAfterExtract = tryQuietly{ deleteAfterExtract.toBoolean() }
32def excludeList = tryQuietly{ (excludeList as File).isAbsolute() ? (excludeList as File) : new File(_args.output ?: '.', excludeList).getCanonicalFile() }
33def myepisodes = tryQuietly{ myepisodes.split(':', 2) }
34def gmail = tryQuietly{ gmail.split(':', 2) }
35def mail = tryQuietly{ mail.split(':', 3) }
36def pushover = tryQuietly{ pushover.split(':', 2) }
37def pushbullet = tryQuietly{ pushbullet.toString() }
38def reportError = tryQuietly{ reportError.toBoolean() }
39
40// user-defined filters
41def label = tryQuietly{ ut_label } ?: null
42def ignore = tryQuietly{ ignore } ?: null
43def minFileSize = tryQuietly{ minFileSize.toLong() }; if (minFileSize == null) { minFileSize = 50 * 1000L * 1000L }
44def minLengthMS = tryQuietly{ minLengthMS.toLong() }; if (minLengthMS == null) { minLengthMS = 10 * 60 * 1000L }
45
46// series/anime/movie format expressions
47def format = [
48 tvs: any{ seriesFormat }{ '''TV Shows/{n}/{episode.special ? 'Special' : 'Season '+s.pad(2)}/{n} - {episode.special ? 'S00E'+special.pad(2) : s00e00} - {vf} - {t.replaceAll(/[`´‘’ʻ]/, /'/).replaceAll(/[!?.]+$/).replacePart(', Part $1')}{'.'+lang}''' },
49 anime: any{ animeFormat }{ '''TV Shows/{primaryTitle}/{primaryTitle} - {sxe} - {vf} - {t.replaceAll(/[!?.]+$/).replaceAll(/[`´‘’ʻ]/, /'/).replacePart(', Part $1')}''' },
50 mov: any{ movieFormat }{ '''Movies/{n} ({y})/{n} ({y}) - {vf}{' CD'+pi}{'.'+lang}''' },
51 music: any{ musicFormat }{ '''Music/{n}/{album+'/'}{pi.pad(2)+'. '}{artist} - {t}''' },
52 unsorted: any{ unsortedFormat }{ '''Unsorted/{file.structurePathTail}''' }
53]
54
55
56// force movie/series/anime logic
57def forceMovie = { f ->
58 label =~ /^(?i:Movie|Couch.Potato)/ || f.dir.listPath().any{ it.name ==~ /(?i:Movies)/ } || f.path =~ /(?<=tt)\\d{7}/
59}
60
61def forceSeries = { f ->
62 label =~ /^(?i:TV|Kids.Shows)/ || f.dir.listPath().any{ it.name ==~ /(?i:TV.Shows)/ } || parseEpisodeNumber(f.path) || parseDate(f.path) || f.path =~ /(?i:tvs-|tvp-|EP[0-9]{2,3}|Season\D?[0-9]{1,2}\D|(19|20)\d{2}.S\d{2})/
63}
64
65def forceAnime = { f ->
66 label =~ /^(?i:Anime)/ || f.dir.listPath().any{ it.name ==~ /(?i:Anime)/ } || (f.isVideo() && (f.name =~ /(?i:HorribleSubs)/ || f.name =~ "[\\(\\[]\\p{XDigit}{8}[\\]\\)]" || (getMediaInfo(file:f, format:'''{media.AudioLanguageList} {media.TextCodecList}''').tokenize().containsAll(['Japanese', 'ASS']) && (parseEpisodeNumber(f.name, false) != null || getMediaInfo(file:f, format:'{minutes}').toInteger() < 60))))
67}
68
69def forceAudio = { f ->
70 label =~ /^(?i:audio|music|music.video)/ || (f.isAudio() && !f.isVideo())
71}
72
73def forceIgnore = { f ->
74 label =~ /^(?i:games|ebook|other|ignore|seeding)/ || f.path.findMatch(ignore) != null
75}
76
77
78
79// include artwork/nfo, pushover/pushbullet and ant utilities as required
80if (artwork || xbmc || plex) { include('lib/htpc') }
81if (pushover || pushbullet ) { include('lib/web') }
82if (gmail || mail) { include('lib/ant') }
83
84
85
86// error reporting functions
87def sendEmailReport = { title, message, messagetype ->
88 if (gmail) {
89 sendGmail(
90 subject: title,
91 message: message,
92 messagemimetype: messagetype,
93 to: any{ mailto } { gmail[0] + '@gmail.com' }, // mail to self by default
94 user: gmail[0],
95 password: gmail[1]
96 )
97 }
98 if (mail) {
99 sendmail(
100 mailhost: mail[0],
101 mailport: mail[1],
102 from: mail[2],
103 to: mailto,
104 subject: title,
105 message: message,
106 messagemimetype: messagetype
107 )
108 }
109}
110
111def fail = { message ->
112 if (reportError) {
113 sendEmailReport('[FileBot] Failure', message, 'text/plain')
114 }
115 die(message)
116}
117
118
119
120// sanity checks
121args.findAll{ !it.exists() }.each{ fail("File not found: $it") }
122
123// check user-defined pre-condition
124if (tryQuietly{ !(ut_state ==~ ut_state_allow) }) {
125 fail("Invalid state: ut_state = $ut_state (expected $ut_state_allow)")
126}
127
128// check ut mode vs standalone mode
129if ((tryQuietly{ ut_dir } == '/') || (args.size() > 0 && (tryQuietly{ ut_dir }?.size() > 0 || tryQuietly{ ut_file }?.size() > 0)) || (args.size() == 0 && (tryQuietly{ ut_dir } == null && tryQuietly{ ut_file } == null))) {
130 fail("Invalid arguments: pass in either file arguments or ut_dir/ut_file parameters but not both")
131}
132
133
134
135// define and load exclude list (e.g. to make sure files are only processed once)
136def excludePathSet = new FileSet()
137if (excludeList) {
138 if (excludeList.exists()) {
139 excludePathSet.feed(Files.lines(excludeList.toPath(), StandardCharsets.UTF_8))
140 log.finest "Using excludes: ${excludeList} (${excludePathSet.size()})"
141 } else if ((!excludeList.parentFile.isDirectory() && !excludeList.parentFile.mkdirs()) || (!excludeList.isFile() && !excludeList.createNewFile())) {
142 die("Failed to create excludeList: ${excludeList}")
143 }
144}
145
146
147// 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)
148def resolveInput(f) {
149 // ignore system and hidden folders
150 if (f.isHidden()) {
151 if (f.isDirectory()) log.finest "Ignore hidden: $f" // ignore all hidden files but only log hidden folders
152 return []
153 }
154
155 // ignore already processed folders
156 if (f.isDirectory() && f.listFiles().toList().any{ it.name ==~ /movie.nfo|tvshow.nfo/ }) {
157 log.finest "Ignore processed folder: $f"
158 return []
159 }
160
161 // resolve recursively
162 if (f.isDirectory() && !f.isDisk())
163 return f.listFiles().toList().findResults{ resolveInput(it) }
164 else
165 return f
166}
167
168// collect input fileset as specified by the given --def parameters
169def roots = []
170if (args.empty) {
171 // assume we're called with utorrent parameters (account for older and newer versions of uTorrents)
172 if (ut_kind == 'single' || (ut_kind != 'multi' && ut_dir && ut_file)) {
173 roots += new File(ut_dir, ut_file) // single-file torrent
174 } else {
175 roots += new File(ut_dir) // multi-file torrent
176 }
177} else {
178 // assume we're called normally with arguments
179 roots += args
180}
181
182// sanitize input
183roots = roots.findAll{ it?.exists() }.collect{ it.canonicalFile }.unique() // roots could be folders as well as files
184
185// flatten nested file structure
186input = roots.flatten{ f -> resolveInput(f) }
187
188// ignore archives that are on the exclude path list
189input = input.findAll{ f -> !excludePathSet.contains(f.path) }
190
191// extract archives (zip, rar, etc) that contain at least one video file
192def extractedArchives = []
193def tempFiles = []
194input = input.flatten{ f ->
195 if (!skipExtract && (f.isArchive() || f.hasExtension('001'))) {
196 def extractDir = new File(f.dir, f.nameWithoutExtension)
197 def extractFiles = extract(file: f, output: new File(extractDir, f.dir.name), conflict: 'auto', filter: { it.isArchive() || it.isVideo() || it.isSubtitle() || (music && it.isAudio()) }, forceExtractAll: true) ?: []
198
199 if (extractFiles.size() > 0) {
200 extractedArchives += f
201 tempFiles += extractDir
202 tempFiles += extractFiles
203 }
204 return extractFiles
205 }
206 return f
207}
208
209
210// ignore files that are on the exclude path list
211input = input.findAll{ f -> !excludePathSet.contains(f.path) }
212
213// update exclude list with all input that will be processed during this run
214if (excludeList && !isTest) {
215 excludeList.withWriterAppend('UTF-8') { out ->
216 extractedArchives.path.each{ out.println(it) }
217 input.path.each{ out.println(it) }
218 }
219}
220
221
222// helper function to work with the structure relative path rather than the whole absolute path
223def relativeInputPath = { f ->
224 def r = roots.find{ r -> f.path.startsWith(r.path) && r.isDirectory() && f.isFile() }
225 if (r != null) {
226 return f.path.substring(r.path.length() + 1)
227 }
228 return f.name
229}
230
231
232// keep original input around so we can print excluded files later
233def originalInputSet = input as LinkedHashSet
234
235// process only media files
236input = input.findAll{ f -> (f.isVideo() && !tryQuietly{ f.hasExtension('iso') && !f.isDisk() }) || f.isSubtitle() || (f.isDirectory() && f.isDisk()) || (music && f.isAudio()) }
237
238// ignore clutter files
239input = input.findAll{ f -> !(relativeInputPath(f) =~ /(?<=\b|_)(?i:sample|trailer|extras|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|_)/) }
240
241// ignore video files that don't conform with the file-size and video-length limits
242input = input.findAll{ f -> !(f.isVideo() && ((minFileSize > 0 && f.length() < minFileSize) || (minLengthMS > 0 && tryQuietly{ getMediaInfo(file:f, format:'{duration}').toLong() < minLengthMS }))) }
243
244// ignore subtitles files that are not stored in the same folder as the movie
245input = input.findAll{ f -> !(f.isSubtitle() && !input.findAll{ it.isVideo() }.any{ f.isDerived(it) }) }
246
247// ensure that the final input set is sorted
248input = input.sort()
249
250// print exclude and input sets for logging
251input.each{ f -> log.finer("Input: $f") }
252(originalInputSet - input).each{ f -> log.finest("Exclude: $f") }
253
254// early abort if there is nothing to do
255if (input.size() == 0) die("No files selected for processing")
256
257
258
259// group episodes/movies and rename according to XBMC standards
260def groups = input.groupBy{ f ->
261 // skip auto-detection if possible
262 if (forceIgnore(f))
263 return []
264 if (music && forceAudio(f)) // process audio only if music mode is enabled
265 return [music: f.dir.name]
266 if (forceMovie(f))
267 return [mov: detectMovie(f, false)]
268 if (forceSeries(f))
269 return [tvs: detectSeriesName(f, true, false) ?: detectSeriesName(input.findAll{ s -> f.dir == s.dir && s.isVideo() }, true, false)]
270 if (forceAnime(f))
271 return [anime: detectSeriesName(f, false, true) ?: detectSeriesName(input.findAll{ s -> f.dir == s.dir && s.isVideo() }, false, true)]
272
273
274 def tvs = detectSeriesName(f, true, false)
275 def mov = detectMovie(f, false)
276 log.fine("$f.name [series: $tvs, movie: $mov]")
277
278 // DECIDE EPISODE VS MOVIE (IF NOT CLEAR)
279 if (tvs && mov) {
280 def norm = { s -> s.ascii().normalizePunctuation().lower().space(' ') }
281 def dn = norm(guessMovieFolder(f)?.name ?: '')
282 def fn = norm(f.nameWithoutExtension)
283 def sn = norm(tvs)
284 def mn = norm(mov.name)
285 def my = mov.year as String
286
287 /*
288 println '--- EPISODE FILTER (POS) ---'
289 println parseEpisodeNumber(fn, true) || parseDate(fn)
290 println ([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)
291 println (fn.after(sn) ==~ /.{0,3} - .+/ && matchMovie(fn) == null)
292 println 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
293 println '--- EPISODE FILTER (NEG) ---'
294 println (mn == fn)
295 println (mov.year >= 1950 && f.listPath().reverse().take(3).find{ it.name.contains(my) && parseEpisodeNumber(it.name.after(my), false) == null })
296 println (mn =~ sn && [dn, fn].find{ it =~ /\b(19|20)\d{2}\b/ && parseEpisodeNumber(it.after(/\b(19|20)\d{2}\b/), false) == null })
297 println '--- MOVIE FILTER (POS) ---'
298 println (fn.contains(mn) && parseEpisodeNumber(fn.after(mn), false) == null)
299 println (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)
300 println ([dn, fn].find{ it =~ mn && !(it.after(mn) =~ /\b\d{1,3}\b/) && (it.getSimilarity(mn) > 0.2 + it.getSimilarity(sn)) } != null)
301 println (detectMovie(f, true) && [dn, fn].find{ it =~ /(19|20)\d{2}/ } != null)
302 */
303
304 // S00E00 | 2012.07.21 | One Piece 217 | Firefly - Serenity | [Taken 1, Taken 2, Taken 3, Taken 4, ..., Taken 10]
305 if ((parseEpisodeNumber(fn, true) || parseDate(fn) || ([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) || (fn.after(sn) ==~ /.{0,3} - .+/ && matchMovie(fn) == null) || 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 || mov.year < 1900) && !( (mn == fn) || (mov.year >= 1950 && f.listPath().reverse().take(3).find{ it.name.contains(my) && parseEpisodeNumber(it.name.after(my), false) == null }) || (mn =~ sn && [dn, fn].find{ it =~ /\b(19|20)\d{2}\b/ && parseEpisodeNumber(it.after(/\b(19|20)\d{2}\b/), false) == null }) ) ) {
306 log.fine("Exclude Movie: $mov")
307 mov = null
308 } else if ((fn.contains(mn) && parseEpisodeNumber(fn.after(mn), false) == null) || (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) || ([dn, fn].find{ it =~ mn && !(it.after(mn) =~ /\b\d{1,3}\b/) && (it.getSimilarity(mn) > 0.2 + it.getSimilarity(sn)) } != null) || (detectMovie(f, false) && [dn, fn].find{ it =~ /(19|20)\d{2}|(?i:CD)[1-9]/ } != null)) {
309 log.fine("Exclude Series: $tvs")
310 tvs = null
311 }
312 }
313
314 // CHECK CONFLICT
315 if (((mov && tvs) || (!mov && !tvs))) {
316 if (failOnError) {
317 fail("Media detection failed")
318 } else {
319 log.fine("Unable to differentiate: [$f.name] => [$tvs] VS [$mov]")
320 return [tvs: null, mov: null, anime: null]
321 }
322 }
323
324 return [tvs: tvs, mov: mov, anime: null]
325}
326
327// group entries by unique tvs/mov descriptor
328groups = groups.groupBy{ group, files -> group.collectEntries{ type, query -> [type, query ? query.toString().ascii().normalizePunctuation().lower() : null] } }.collectEntries{ group, maps -> [group, maps.values().flatten()] }
329
330// log movie/series/anime detection results
331groups.each{ group, files -> log.finest("Group: $group => ${files*.name}") }
332
333// process each batch
334groups.each{ group, files ->
335 // fetch subtitles (but not for anime)
336 if (group.anime == null && subtitles != null && files.findAll{ it.isVideo() }.size() > 0) {
337 subtitles.each{ languageCode ->
338 def subtitleFiles = getMissingSubtitles(file:files, lang:languageCode, strict:true, output:'srt', encoding:'UTF-8', db: 'OpenSubtitles', format:'MATCH_VIDEO_ADD_LANGUAGE_TAG') ?: []
339 files += subtitleFiles
340 input += subtitleFiles // make sure subtitles are added to the exclude list and other post processing operations
341 tempFiles += subtitleFiles // if downloaded for temporarily extraced files delete later
342 }
343 }
344
345 // EPISODE MODE
346 if ((group.tvs || group.anime) && !group.mov) {
347 // choose series / anime config
348 def config = group.tvs ? [name:group.tvs, format:format.tvs, db:'TheTVDB']
349 : [name:group.anime, format:format.anime, db:'AniDB']
350 def dest = rename(file: files, format: config.format, db: config.db)
351 if (dest && artwork) {
352 dest.mapByFolder().each{ dir, fs ->
353 def hasSeasonFolder = (config.format =~ /(?i)Season/)
354 def sxe = fs.findResult{ eps -> parseEpisodeNumber(eps) }
355 def seriesName = detectSeriesName(fs, true, false)
356 def options = TheTVDB.search(seriesName, _args.locale)
357 if (options.isEmpty()) {
358 log.warning "TV Series not found: $config.name"
359 return
360 }
361 def series = options.sortBySimilarity(seriesName, { s -> s.name }).get(0)
362 log.fine "Fetching series artwork for [$series] to [$dir]"
363 fetchSeriesArtworkAndNfo(hasSeasonFolder ? dir.dir : dir, dir, series, sxe && sxe.season > 0 ? sxe.season : 1, false, _args.locale)
364 }
365 }
366 if (dest == null && failOnError) {
367 fail("Failed to rename series: $config.name")
368 }
369 }
370
371 // MOVIE MODE
372 else if (group.mov && !group.tvs && !group.anime) {
373 def dest = rename(file:files, format:format.mov, db:'TheMovieDB')
374 if (dest && artwork) {
375 dest.mapByFolder().each{ dir, fs ->
376 def movieFile = fs.findAll{ it.isVideo() || it.isDisk() }.sort{ it.length() }.reverse().findResult{ it }
377 if (movieFile != null) {
378 def movie = detectMovie(movieFile, false)
379 log.fine "Fetching movie artwork for [$movie] to [$dir]"
380 fetchMovieArtworkAndNfo(dir, movie, movieFile, extras, false, _args.locale)
381 }
382 }
383 }
384 if (dest == null && failOnError) {
385 fail("Failed to rename movie: $group.mov")
386 }
387 }
388
389 // MUSIC MODE
390 else if (group.music) {
391 def dest = rename(file:files, format:format.music, db:'AcoustID')
392 if (dest == null && failOnError) {
393 fail("Failed to rename music: $group.music")
394 }
395 }
396}
397
398
399// ---------- POST PROCESSING ---------- //
400
401// deal with remaining files that cannot be sorted automatically
402if (unsorted) {
403 def unsortedFiles = (input - getRenameLog().keySet())
404 if (unsortedFiles.size() > 0) {
405 log.info "Processing ${unsortedFiles.size()} unsorted files"
406 rename(map: unsortedFiles.collectEntries{ original ->
407 [original, new File(_args.output, getMediaInfo(file: original, format: format.unsorted))]
408 })
409 }
410}
411
412// run program on newly processed files
413if (exec) {
414 getRenameLog().each{ from, to ->
415 def command = getMediaInfo(format: exec, file: to)
416 log.finest("Execute: $command")
417 execute(command)
418 }
419}
420
421
422// ---------- REPORTING ---------- //
423
424
425if (getRenameLog().size() > 0) {
426
427 // messages used for xbmc / plex / pushover notifications
428 def getNotificationTitle = { "FileBot finished processing ${getRenameLog().values().findAll{ !it.isSubtitle() }.size()} files" }.memoize()
429 def getNotificationMessage = { prefix = '• ', postfix = '\n' -> tryQuietly{ ut_title } ?: (input.any{ !it.isSubtitle() } ? input.findAll{ !it.isSubtitle() } : input).collect{ relativeInputPath(it) as File }*.getRoot()*.getNameWithoutExtension().unique().sort{ it.toLowerCase() }.collect{ prefix + it }.join(postfix).trim() }.memoize()
430
431 // make XMBC scan for new content and display notification message
432 if (xbmc) {
433 xbmc.each{ host ->
434 log.info "Notify XBMC: $host"
435 tryLogCatch{
436 showNotification(host, 9090, getNotificationTitle(), getNotificationMessage(), 'http://app.filebot.net/icon.png')
437 scanVideoLibrary(host, 9090)
438 }
439 }
440 }
441
442 // make Plex scan for new content
443 if (plex) {
444 plex.each{ instance ->
445 log.info "Notify Plex: ${instance.host}"
446 tryLogCatch {
447 refreshPlexLibrary(instance.host, 32400, instance.token)
448 }
449 }
450 }
451
452 // mark episodes as 'acquired'
453 if (myepisodes) {
454 log.info 'Update MyEpisodes'
455 tryLogCatch {
456 executeScript('update-mes', [login:myepisodes.join(':'), addshows:true], getRenameLog().values())
457 }
458 }
459
460 if (pushover) {
461 log.info 'Sending Pushover notification'
462 tryLogCatch {
463 Pushover(pushover[0], pushover.length == 1 ? 'wcckDz3oygHSU2SdIptvnHxJ92SQKK' : pushover[1]).send(getNotificationTitle(), getNotificationMessage())
464 }
465 }
466
467 // messages used for email / pushbullet reports
468 def getReportSubject = { getNotificationMessage('', ', ') }
469 def getReportTitle = { '[FileBot] ' + getReportSubject() }
470 def getReportMessage = {
471 def renameLog = getRenameLog()
472 '''<!DOCTYPE html>\n''' + XML {
473 html {
474 head {
475 meta(charset:'UTF-8')
476 style('''
477 p{font-family:Arial,Helvetica,sans-serif}
478 p b{color:#07a}
479 hr{border-style:dashed;border-width:1px 0 0 0;border-color:lightgray}
480 small{color:#d3d3d3;font-size:xx-small;font-weight:normal;font-family:Arial,Helvetica,sans-serif}
481 table a:link{color:#666;font-weight:bold;text-decoration:none}
482 table a:visited{color:#999;font-weight:bold;text-decoration:none}
483 table a:active,table a:hover{color:#bd5a35;text-decoration:underline}
484 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}
485 table th{padding:15px;border-top:1px solid #fafafa;border-bottom:1px solid #e0e0e0;background:#ededed}
486 table th{text-align:center;padding-left:20px}
487 table tr:first-child th:first-child{border-top-left-radius:3px}
488 table tr:first-child th:last-child{border-top-right-radius:3px}
489 table tr{text-align:left;padding-left:20px}
490 table td:first-child{text-align:left;padding-left:20px;border-left:0}
491 table td{padding:15px;border-top:1px solid #fff;border-bottom:1px solid #e0e0e0;border-left:1px solid #e0e0e0;background:#fafafa;white-space:nowrap}
492 table tr.even td{background:#f6f6f6}
493 table tr:last-child td{border-bottom:0}
494 table tr:last-child td:first-child{border-bottom-left-radius:3px}
495 table tr:last-child td:last-child{border-bottom-right-radius:3px}
496 table tr:hover td{background:#f2f2f2}
497 ''')
498 title(getReportTitle())
499 }
500 body {
501 p {
502 mkp.yield("FileBot finished processing ")
503 b(getReportSubject())
504 mkp.yield(" (${renameLog.size()} files).")
505 }
506 hr(); table {
507 tr { th('Original Name'); th('New Name'); th('New Location') }
508 renameLog.each{ from, to ->
509 tr { [from.name, to.name, to.parent].each{ cell -> td(cell) } }
510 }
511 }
512 hr(); small("// Generated by ${Settings.getApplicationIdentifier()} on ${InetAddress.localHost.hostName} at ${now.dateTimeString}")
513 }
514 }
515 }
516 }
517
518 // store processing report
519 if (storeReport) {
520 def reportFolder = new File(Settings.getApplicationFolder(), 'reports').getCanonicalFile()
521 def reportFile = getReportMessage().saveAs(new File(reportFolder, "AMC ${now.format('''[yyyy-MM-dd HH'h'mm'm']''')} ${getReportSubject().take(50).trim()}.html".validateFileName()))
522 log.finest("Saving report as ${reportFile}")
523 }
524
525 // send pushbullet report
526 if (pushbullet) {
527 log.info 'Sending PushBullet report'
528 tryLogCatch {
529 PushBullet(pushbullet).sendFile(getNotificationTitle(), getReportMessage(), 'text/html', getNotificationMessage(), tryQuietly{ mailto })
530 }
531 }
532
533 // send email report
534 if (gmail || mail) {
535 tryLogCatch {
536 sendEmailReport(getReportTitle(), getReportMessage(), 'text/html')
537 }
538 }
539}
540
541
542// ---------- CLEAN UP ---------- //
543
544
545// clean up temporary files that may be left behind after extraction
546if (deleteAfterExtract) {
547 extractedArchives.each{ a ->
548 log.finest("Delete archive $a")
549 a.delete()
550 a.dir.listFiles().toList().findAll{ v -> v.name.startsWith(a.nameWithoutExtension) && v.extension ==~ /r\d+/ }.each{ v ->
551 log.finest("Delete archive volume $v")
552 v.delete()
553 }
554 }
555}
556
557// clean empty folders, clutter files, etc after move
558if (clean) {
559 if (['COPY', 'HARDLINK'].find{ it.equalsIgnoreCase(_args.action) } && tempFiles.size() > 0) {
560 log.info 'Clean temporary extracted files'
561 // delete extracted files
562 tempFiles.findAll{ it.isFile() }.sort().each{
563 log.finest "Delete $it"
564 it.delete()
565 }
566 // delete remaining empty folders
567 tempFiles.findAll{ it.isDirectory() }.sort().reverse().each{
568 log.finest "Delete $it"
569 if (it.getFiles().isEmpty()) it.deleteDir()
570 }
571 }
572
573 // deleting remaining files only makes sense after moving files
574 if ('MOVE'.equalsIgnoreCase(_args.action)) {
575 def cleanerInput = !args.empty ? args : ut_kind == 'multi' && ut_dir ? [ut_dir as File] : []
576 cleanerInput = cleanerInput.findAll{ f -> f.exists() }
577 if (cleanerInput.size() > 0) {
578 log.info 'Clean clutter files and empty folders'
579 executeScript('cleaner', args.empty ? [root:true, ignore: ignore] : [root:false, ignore: ignore], cleanerInput)
580 }
581 }
582}
583
584
585if (getRenameLog().size() == 0) fail("Finished without processing any files")