Unofficial custom AMC script (intermediate users)

Running FileBot from the console, Groovy scripting, shell scripts, etc
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Unofficial custom AMC script (intermediate users)

Post by kim »

UPDATE:
viewtopic.php?f=4&t=5451&p=31192#p31191

warning, this is a very modified version of amc+htpc (2in1)

This script now support:
TV episodes from both thetvdb and themoviedb (Kodi style + a lot more info)
Movies (Kodi style + a lot more info)

Use like official AMC, but first you most change:
def TheTVDBv2 = new TheTVDBClient(/YOUR_KEY_HERE/)
def tmdbAPIKey = "?api_key=YOUR_KEY_HERE"
maybe rednoah will let you/us use his api keys ?

To get TV from themoviedb:
--def tmdbTV=y (default is use thetvdb)

Other new/changed stuff:
--def nfoOnly=y (get/make ONLY Movie NFO, not active on TV)
--def extras=y

sorry but "The maximum number of allowed characters is 60000."
so I most split it up in 2 posts

copy and save this as SOMETHING.groovy e.g. "amc_custom.groovy"

Code: Select all

#!/usr/bin/env filebot -script

import static groovy.json.StringEscapeUtils.*
import static net.filebot.web.OpenSubtitlesHasher.*

def formatType = []
_args.args.each{ n -> n =~ /=@/ ? formatType << n :'' }
def override = _args.conflict.equalsIgnoreCase('override')
def logFile = any{_args.logFile }{ 'No log file' }
def locale = any{ _args.language.locale }{ Locale.US }
def nfoOnly = tryQuietly{ nfoOnly.toBoolean() }
def tmdbTV = tryQuietly{ tmdbTV.toBoolean() }
def tvDB = tmdbTV ? 'TheMovieDB::TV' : 'TheTVDB'
log.info "FROM custom amc - tvDB: [${tvDB}]"

// log input parameters
log.info("FROM custom amc - Run script [$_args.script] at [$now]")
log.info("FROM custom amc - Log file [$logFile]")
log.info("FROM custom amc - Parameter: Language = [$locale]")
log.info("FROM custom amc - Parameter: Override = [$override]")
_def.each{ n, v -> log.finest('FROM custom amc - Parameter: ' + [n, n =~ /plex|kodi|pushover|pushbullet|mail|myepisodes|.+Format/ ? n =~ /.+Format/ ? formatType : '*****' : v].join(' = ')) }
args.withIndex().each{ f, i -> if (f.exists()) { log.finest "FROM custom amc - Argument[$i]: $f" } else { log.warning "FROM custom amc - Argument[$i]: Does not exist: $f" } }

// initialize variables
failOnError = _args.conflict.equalsIgnoreCase('fail')
testRun = _args.action.equalsIgnoreCase('test')

// --output folder must be a valid folder
outputFolder = tryLogCatch{ any{ _args.output }{ '.' }.toFile().getCanonicalFile() }

// enable/disable features as specified via --def parameters
unsorted  = tryQuietly{ unsorted.toBoolean() }
music     = tryQuietly{ music.toBoolean() }
subtitles = tryQuietly{ subtitles.split(/\W+/) as List }
artwork   = tryQuietly{ artwork.toBoolean() && !testRun }
extras    = tryQuietly{ extras.toBoolean() }
clean     = tryQuietly{ clean.toBoolean() }
exec      = tryQuietly{ exec.toString() }

// array of kodi/plex/emby hosts
kodi = tryQuietly{ any{kodi}{xbmc}.split(/[ ,;|]+/)*.split(/:(?=\d+$)/).collect{ it.length >= 2 ? [host: it[0], port: it[1] as int] : [host: it[0]] } }
plex = tryQuietly{ plex.split(/[ ,;|]+/)*.split(/:/).collect{ it.length >= 2 ? [host: it[0], token: it[1]] : [host: it[0]] } }
emby = tryQuietly{ emby.split(/[ ,;|]+/)*.split(/:/).collect{ it.length >= 2 ? [host: it[0], token: it[1]] : [host: it[0]] } }

// extra options, myepisodes updates and email notifications
extractFolder      = tryQuietly{ extractFolder as File }
skipExtract        = tryQuietly{ skipExtract.toBoolean() }
deleteAfterExtract = tryQuietly{ deleteAfterExtract.toBoolean() }
excludeList        = tryQuietly{ def f = excludeList as File; f.isAbsolute() ? f : outputFolder.resolve(f.path) }
myepisodes         = tryQuietly{ myepisodes.split(':', 2) as List }
gmail              = tryQuietly{ gmail.split(':', 2) as List }
mail               = tryQuietly{ mail.split(':', 5) as List }
pushover           = tryQuietly{ pushover.split(':', 2) as List }
pushbullet         = tryQuietly{ pushbullet.toString() }
storeReport        = tryQuietly{ storeReport.toBoolean() }
reportError        = tryQuietly{ reportError.toBoolean() }

// user-defined filters
label       = any{ _args.mode }{ ut_label }{ null }
ignore      = any{ ignore }{ null }
minFileSize = any{ minFileSize.toLong() }{ 50 * 1000L * 1000L }
minLengthMS = any{ minLengthMS.toLong() }{ 10 * 60 * 1000L }

// series/anime/movie format expressions
seriesFormat   = any{ seriesFormat   }{ '{plex}' }
animeFormat    = any{ animeFormat    }{ '{plex}' }
movieFormat    = any{ movieFormat    }{ '{plex}' }
musicFormat    = any{ musicFormat    }{ '{plex}' }
unsortedFormat = any{ unsortedFormat }{ 'Unsorted/{file.structurePathTail}' }



// force Movie / TV Series / Anime behaviour
def forceMovie(f) {
	label =~ /^(?i:Movie|Film|Concert|UFC)/ || f.dir.listPath().any{ it.name ==~ /(?i:Movies|Movie)/ } || f.isMovie() || any{ f.isVideo() && !forceSeries(f) && getMediaInfo(f, '{minutes}').toInteger() >= 100 }{ false }
}

def forceSeries(f) {
	label =~ /^(?i:TV|Show|Series|Documentary)/ || f.dir.listPath().any{ it.name ==~ /(?i:TV.Shows|TV.Series|TV)/ } || f.path =~ /(?<=\b|_)(?i:tv[sp]-|Season\D?\d{1,2}|\d{4}.S\d{2})(?=\b|_)/ || parseEpisodeNumber(f.path, true) || parseDate(f.path)
}

def forceAnime(f) {
	label =~ /^(?i:Anime)/ || f.dir.listPath().any{ it.name ==~ /(?i:Anime)/ } || (f.isVideo() && (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 }))
}

def forceAudio(f) {
	label =~ /^(?i:audio|music|music.video)/ || (f.isAudio() && !f.isVideo())
}

def forceIgnore(f) {
	label =~ /^(?i:games|ebook|other|ignore)/
}



// include artwork/nfo, pushover/pushbullet and ant utilities as required
// if (artwork || kodi || plex || emby) { include('lib/htpc') }
if (pushover || pushbullet ) { include('lib/web') }
if (gmail || mail) { include('lib/ant') }



// error reporting functions
def sendEmailReport(title, message, messagetype) {
	if (gmail) {
		sendGmail(
			subject: title, message: message, messagemimetype: messagetype,
			to: any{ mailto } { gmail[0].contains('@') ? gmail[0] : gmail[0] + '@gmail.com' },		// mail to self by default
			user: gmail[0].contains('@') ? gmail[0] : gmail[0] + '@gmail.com', password: gmail[1]
		)
	}
	if (mail) {
		sendmail(
			subject: title, message: message, messagemimetype: messagetype,
			mailhost: mail[0], mailport: mail[1], from: mail[2], to: mailto,
			user: mail[3], password: mail[4]
		)
	}
}

def fail(message) {
	if (reportError) {
		sendEmailReport('[FileBot] Failure', message as String, 'text/plain')
	}
	die(message)
}



// check input parameters
def ut = _def.findAll{ k, v -> k.startsWith('ut_') }.collectEntries{ k, v ->
	if (v ==~ /[%$]\p{Alnum}|\p{Punct}+/) {
		log.warning "FROM custom amc - Bad $k value: $v"
		v = null
	}
	return [k.substring(3), v ? v : null]
}



// sanity checks
if (outputFolder == null || !outputFolder.isDirectory()) {
	fail "Illegal usage: output folder must exist and must be a directory: $outputFolder"
}

if (ut.dir) {
	if (ut.state_allow && !(ut.state ==~ ut.state_allow)) {
		fail "Illegal state: $ut.state != $ut.state_allow"
	}
	if (args.size() > 0) {
		fail "Illegal usage: use either script parameters $ut or file arguments $args but not both"
	}
	if (ut.dir == '/') {
		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."
	}
	if (ut.dir.toFile() in outputFolder.listPath()) {
		fail "Illegal usage: output folder [$outputFolder] must be separate from input folder $ut"
	}
} else if (args.size() == 0) {
	fail "Illegal usage: no input"
} else if (args.any{ f -> f in outputFolder.listPath() }) {
	fail "Illegal usage: output folder [$outputFolder] must be separate from input arguments $args"
} else if (args.any{ f -> f in File.listRoots() }) {
	fail "Illegal usage: input $args must not include a filesystem root"
}



// collect input fileset as specified by the given --def parameters
roots = args

if (args.size() == 0) {
	// assume we're called with utorrent parameters (account for older and newer versions of uTorrents)
	if (ut.kind == 'single' || (ut.kind != 'multi' && ut.dir && ut.file)) {
		roots = [new File(ut.dir, ut.file).getCanonicalFile()] // single-file torrent
	} else {
		roots = [new File(ut.dir).getCanonicalFile()] // multi-file torrent
	}
}

// helper function to work with the structure relative path rather than the whole absolute path
def relativeInputPath(f) {
	def r = roots.find{ r -> f.path.startsWith(r.path) && r.isDirectory() && f.isFile() }
	if (r != null) {
		return f.path.substring(r.path.length() + 1)
	}
	return f.name
}



// define and load exclude list (e.g. to make sure files are only processed once)
excludePathSet = new FileSet()

if (excludeList) {
	if (excludeList.exists()) {
		try {
			excludePathSet.load(excludeList)
		} catch(Exception e) {
			fail "Failed to load excludeList: $e"
		}
		log.fine "FROM custom amc - Use excludes: $excludeList (${excludePathSet.size()})"
	} else {
		log.fine "FROM custom amc - Use excludes: $excludeList"
		if ((!excludeList.parentFile.isDirectory() && !excludeList.parentFile.mkdirs()) || (!excludeList.isFile() && !excludeList.createNewFile())) {
			fail "Failed to create excludeList: $excludeList"
		}
	}
}


extractedArchives = []
temporaryFiles = []

def extract(f) {
	def folder = new File(extractFolder ?: f.dir, f.nameWithoutExtension)
	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) ?: []

	extractedArchives += f
	temporaryFiles += folder
	temporaryFiles += files

	return files
}


def acceptFile(f) {
	if (f.isHidden()) {
		log.finest "FROM custom amc - Ignore hidden: $f"
		return false
	}

	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/) {
		log.finest "FROM custom amc - Ignore system path: $f"
		return false
	}

	if (f.name =~ /(?<=\b|_)(?i:Sample|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|_)/) {
		log.finest "FROM custom amc - Ignore extra: $f"
		return false
	}

	// ignore if the user-defined ignore pattern matches
	if (f.path.findMatch(ignore)) {
		log.finest "FROM custom amc - Ignore pattern: $f"
		return false
	}

	// ignore archives that are on the exclude path list
	if (excludePathSet.contains(f)) {
		return false
	}

	// accept folders right away and skip file sanity checks
	if (f.isDirectory()) {
		return true
	}

	// accept archives if the extract feature is enabled
	if (f.isArchive() || f.hasExtension('001')) {
		return !skipExtract
	}

	// ignore iso images that do not contain a video disk structure
	if (f.hasExtension('iso') && !f.isDisk()) {
		log.fine "FROM custom amc - Ignore disk image: $f"
		return false
	}

	// ignore small video files
	if (minFileSize > 0 && f.isVideo() && f.length() < minFileSize) {
		log.fine "FROM custom amc - Skip small video file: $f"
		return false
	}

	// ignore short videos
	if (minLengthMS > 0 && f.isVideo() && any{ getMediaInfo(f, '{minutes}').toLong() * 60 * 1000L < minLengthMS }{ false /* default if MediaInfo fails */ }) {
		log.fine "FROM custom amc - Skip short video: $f"
		return false
	}

	// ignore subtitle files without matching video file in the same or parent folder
	if (f.isSubtitle() && ![f, f.dir].findResults{ it.dir }.any{ it.listFiles{ it.isVideo() && f.isDerived(it) }}) {
		log.fine "FROM custom amc - Ignore orphaned subtitles: $f"
		return false
	}

	// process only media files (accept audio files only if music mode is enabled)
	return f.isVideo() || f.isSubtitle() || (music && f.isAudio())
}


// 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)
def resolveInput(f) {
	// resolve folder recursively, except disk folders
	if (f.isDirectory()) {
		if (f.isDisk()) {
			return f
		}
		return f.listFiles{ acceptFile(it) }.collect{ resolveInput(it) }
	}

	if (f.isArchive() || f.hasExtension('001')) {
		return extract(f).findAll{ acceptFile(it) }.collect{ resolveInput(it) }
	}

	return f
}



// flatten nested file structure
def input = roots.findAll{ acceptFile(it) }.flatten{ resolveInput(it) }.toSorted()

// update exclude list with all input that will be processed during this run
if (excludeList && !testRun) {
	excludePathSet.append(excludeList, extractedArchives, input)
}

// print exclude and input sets for logging
input.each{ log.fine "FROM custom amc - Input: $it" }

// early abort if there is nothing to do
if (input.size() == 0) {
	log.warning "FROM custom amc - No files selected for processing"
	return
}



// group episodes/movies and rename according to Plex standards
def groups = input.groupBy{ f ->
	// print xattr metadata
	if (f.metadata) {
		log.finest "FROM custom amc groups - xattr: [$f.name] => [$f.metadata]"
	}

	// skip auto-detection if possible
	if (forceIgnore(f))
		return []
	if (music && forceAudio(f)) // process audio only if music mode is enabled
		return [music: f.dir.name]
	if (forceMovie(f))
		return [mov:   detectMovie(f, false)]
	if (forceSeries(f))
		return [tvs:   detectSeriesName(f) ?: detectSeriesName(input.findAll{ s -> f.dir == s.dir && s.isVideo() })]
	if (forceAnime(f))
		return [anime: detectAnimeName(f) ?: detectAnimeName(input.findAll{ s -> f.dir == s.dir && s.isVideo() })]


	def tvs = detectSeriesName(f)
	def mov = detectMovie(f, false)
	log.fine "FROM custom amc - $f.name [series: $tvs, movie: $mov]"

	// DECIDE EPISODE VS MOVIE (IF NOT CLEAR)
	if (tvs && mov) {
		log.fine "FROM custom amc groups - DECIDE EPISODE VS MOVIE (IF NOT CLEAR)"
		def norm = { s -> s.ascii().normalizePunctuation().lower().space(' ') }
		def dn = norm(guessMovieFolder(f)?.name ?: '')
		log.fine "FROM custom amc groups - DECIDE EPISODE VS MOVIE:  [$dn] [MediaDetection.java]"
		def fn = norm(f.nameWithoutExtension)
		def sn = norm(tvs)
		def mn = norm(mov.name)
		def my = mov.year as String

		// S00E00 | 2012.07.21 | One Piece 217 | Firefly - Serenity | [Taken 1, Taken 2, Taken 3, Taken 4, ..., Taken 10]
		def metrics = [
			[tvs: -1, mov:  0, fun: { mn == fn } ],
			[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 } } ],
			[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 } } ],
			[tvs:  5, mov: -1, fun: { parseEpisodeNumber(fn, true) || parseDate(fn) } ],
			[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 } ],
			[tvs:  1, mov: -1, fun: { fn.after(sn) ==~ /.{0,3}\s-\s.+/ && matchMovie(fn) == null } ],
			[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 } ],
			[tvs: -1, mov:  1, fun: { fn =~ /tt\d{7}/ } ],
			[tvs: -1, mov:  1, fun: { f.nameWithoutExtension ==~ /[\D\s_.]+/ } ],
			[tvs: -1, mov:  5, fun: { detectMovie(f, true) && [dn, fn].find{ it =~ /(19|20)\d{2}/ } != null } ],
			[tvs: -1, mov:  1, fun: { fn.contains(mn) && parseEpisodeNumber(fn.after(mn), false) == null } ],
			[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 } ],
			[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 } ],
			[tvs: -1, mov:  1, fun: { detectMovie(f, false).aliasNames.find{ fn.contains(norm(it)) } } ]
		]

		def score = [tvs: 0, mov: 0]
		metrics.each{
			if (tvs && mov && it.fun()) {
				score.tvs += it.tvs
				score.mov += it.mov

				if (score.tvs >= 1 && score.mov <= -1) {
					log.fine "FROM custom amc - Exclude Movie: $mov"
					mov = null
				} else if (score.mov >= 1 && score.tvs <= -1) {
					log.fine "FROM custom amc - Exclude Series: $tvs"
					tvs = null
				}
			}
		}

	}

	// CHECK CONFLICT
	if (((mov && tvs) || (!mov && !tvs))) {
		if (failOnError) {
			fail 'Media detection failed'
		} else {
			log.fine "FROM custom amc - Unable to differentiate: [$f.name] => [$tvs] VS [$mov]"
			return [:]
		}
	}

	return [tvs: tvs, mov: mov]
}

// group entries by unique tvs/mov descriptor
groups = groups.groupBy{ group, files -> group.collectEntries{ type, query -> [type, query ? query.toString().ascii().normalizePunctuation().lower() : null] } }.collectEntries{ group, maps -> [group, maps.values().flatten()] }

// log movie/series/anime detection results
groups.each{ group, files -> log.finest "FROM custom amc Group: $group => ${files*.name}" }

// keep track of files that have been processed successfully
def destinationFiles = []

// keep track of unsorted files or files that could not be processed for some reason
def unsortedFiles = []

// process each batch
groups.each{ group, files ->
	// fetch subtitles (but not for anime)
	if (group.anime == null && subtitles != null && files.findAll{ it.isVideo() }.size() > 0) {
		subtitles.each{ languageCode ->
			def subtitleFiles = getMissingSubtitles(file: files, lang: languageCode, strict: true, output: 'srt', encoding: 'UTF-8', format: 'MATCH_VIDEO_ADD_LANGUAGE_TAG') ?: []
			files += subtitleFiles
			input += subtitleFiles // make sure subtitles are added to the exclude list and other post processing operations
			temporaryFiles += subtitleFiles // if downloaded for temporarily extraced files delete later
		}
	}

	// EPISODE MODE
	if ((group.tvs || group.anime) && !group.mov) {
		// choose series / anime

		def dest = group.tvs ? rename(file: files, format: seriesFormat, db: tvDB) : rename(file: files, format: animeFormat, db: 'AniDB')

		if (dest != null) {
			destinationFiles += dest

			if (artwork) {
				dest.mapByFolder().each{ dir, fs ->
					def hasSeasonFolder = any{ dir =~ /Specials|Season.\d+/ || dir.parentFile.structurePathTail.listPath().size() > 0 }{ false }	// MAY NOT WORK FOR CERTAIN FORMATS

					fs.each{epFile ->
						if (epFile.metadata instanceof MultiEpisode){
							log.fine "FROM custom amc Episode Mode artwork - FileName: ${epFile}"
							log.fine "FROM custom amc Episode Mode artwork - MultiEpisode: ${epFile.metadata} / Ids: ${epFile.metadata.episodes.id}"
							log.fine "FROM custom amc Episode Mode artwork - Fetching series artwork for [$epFile.metadata.seriesName] / Season: [$epFile.metadata.season] / Episodes: $epFile.metadata.episodes.id / Title: $epFile.metadata.episodes.title to [$dir]"
							fetchSeriesArtworkAndNfo(hasSeasonFolder ? dir.parentFile : dir, dir, epFile.metadata.seriesInfo.id, epFile.metadata.special ? 0 : epFile.metadata.season, override, locale, epFile, epFile.metadata.special ? epFile.metadata.episodes.special : epFile.metadata.episodes.episode, epFile.metadata.toString().matchAll(/\d{1,2}x\d{2}/), _args.order, epFile.metadata.episodes ? epFile.metadata.episodes.id : epFile.metadata.id, extras, tvDB)
						}
						else {
							log.fine "FROM custom amc Episode Mode artwork - FileName: [${epFile}]"
							log.fine "FROM custom amc Episode Mode artwork - Episode: [${epFile.metadata}] / Id: [${epFile.metadata.id}]"
							log.fine "FROM custom amc Episode Mode artwork - Fetching series artwork for [$epFile.metadata.seriesName] / Season: [$epFile.metadata.season] / Episode: [$epFile.metadata.episode] / Title: [$epFile.metadata.title] to [$dir]"
							fetchSeriesArtworkAndNfo(hasSeasonFolder ? dir.parentFile : dir, dir, epFile.metadata.seriesInfo.id, epFile.metadata.special ? 0 : epFile.metadata.season, override, locale, epFile, epFile.metadata.special ? epFile.metadata.special : epFile.metadata.episode, epFile.metadata.special ? 0 + 'x' + epFile.metadata.special.pad(2) : epFile.metadata.season + 'x' + epFile.metadata.episode.pad(2), _args.order, epFile.metadata.id, extras, tvDB)
						}
					}

				}
			}
		} else if (failOnError) {
			fail "Failed to process group: $group"
		} else {
			unsortedFiles += files
		}
	}

	// MOVIE MODE
	else if (group.mov && !group.tvs && !group.anime) {
		def trailerFile = files.findAll{ it.getName().matches(/(?i:.+\-Trailer\.\w{3})/) }.findResults{ it }
		files = files.findAll{ !it.getName().matches(/(?i:.+\-Trailer\.\w{3})/) }.findResults{ it }
		if (trailerFile.size() != 0) {log.fine "FROM custom amc Movie Mode - trailerFile: $trailerFile"}
		def dest = rename(file: files, format: movieFormat, db: 'TheMovieDB')
		if (trailerFile && dest){
			trailerFile.each{
				def ext = it.toString().after(/-trailer/)
				def newTrailerName = dest.nameWithoutExtension.join()+"-trailer"+ext
				log.fine "FROM custom amc Movie Mode - Rename Trailer File: [${it.getName()}] to [${newTrailerName}]"
				log.fine "FROM custom amc Movie Mode - Moving Trailer File: [${newTrailerName}] to ${dest.parentFile}"
				it.renameTo(new File(dest.parentFile.join(), newTrailerName))
			}
		}

		if (dest != null) {
			destinationFiles += dest

			if (artwork) {
				dest.mapByFolder().each{ dir, fs ->
					def movieFiles = fs.findAll{ it.isVideo() || it.isDisk() }.sort().findResults{ it }
					log.fine "FROM custom amc Movie Mode artwork - movieFiles: $movieFiles"

					def movieFile = fs.findAll{ it.isVideo() || it.isDisk() }.sort{ it.length() }.reverse().findResult{ it }
					if (movieFile) {
						def movieInfo = movieFile.metadata
						log.fine "FROM custom amc Movie Mode - Fetching movie NFO and artwork for [$movieInfo] to [$dir]"
						fetchMovieArtworkAndNfo(dir, movieInfo, movieFile, extras, override, locale, movieFiles, nfoOnly)
					}
				}
			}
		} else if (failOnError) {
			fail "Failed to process group: $group"
		} else {
			unsortedFiles += files
		}
	}

	// MUSIC MODE
	else if (group.music) {
		def dest = rename(file: files, format: musicFormat, db: 'ID3')

		if (dest != null) {
			destinationFiles += dest
		} else if (failOnError) {
			fail "Failed to process group: $group"
		} else {
			unsortedFiles += files
		}
	}

	// UNSORTED
	else {
		unsortedFiles += files
	}
}


// ---------- POST PROCESSING ---------- //

// deal with remaining files that cannot be sorted automatically
if (unsorted) {
	if (unsortedFiles.size() > 0) {
		log.fine "FROM custom amc - Processing ${unsortedFiles.size()} unsorted files"

		def dest = rename(map: unsortedFiles.collectEntries{ original ->
			def destination = getMediaInfo(original, unsortedFormat) as File

			// sanity check user-defined unsorted format
			if (destination == null) {
				fail("Illegal usage: unsorted format must yield valid file path")
			}

			// resolve relative paths
			if (!destination.isAbsolute()) {
				destination = outputFolder.resolve(destination.path)
			}

			return [original, destination]
		})

		if (dest != null) {
			destinationFiles += dest
		}
	}
}

// run program on newly processed files
if (exec) {
	destinationFiles.collect{ getMediaInfo(it, exec) }.unique().each{ command ->
		log.fine "FROM custom amc - Execute: $command"
		execute(command)
	}
}


// ---------- REPORTING ---------- //


if (getRenameLog().size() > 0) {
	// messages used for kodi / plex / emby pushover notifications
	def getNotificationTitle = {
		def count = getRenameLog().count{ k, v -> !v.isSubtitle() }
		return "FileBot finished processing $count files"
	}.memoize()

	def getNotificationMessage = { prefix = '• ', postfix = '\n' ->
		return ut.title ?: (input.findAll{ !it.isSubtitle() } ?: input).collect{ relativeInputPath(it) as File }.root.nameWithoutExtension.unique().collect{ prefix + it }.join(postfix).trim()
	}.memoize()

	// make Kodi scan for new content and display notification message
	if (kodi) {
		kodi.each{ instance ->
			log.fine "FROM custom amc - Notify Kodi: $instance"
			tryLogCatch {
				showNotification(instance.host, instance.port ?: 8080, getNotificationTitle(), getNotificationMessage(), 'https://app.filebot.net/icon.png')
				scanVideoLibrary(instance.host, instance.port ?: 8080)
			}
		}
	}

	// make Plex scan for new content
	if (plex) {
		plex.each{ instance ->
			log.fine "FROM custom amc - Notify Plex: $instance"
			tryLogCatch {
				refreshPlexLibrary(instance.host, 32400, instance.token)
			}
		}
	}

	// make Emby scan for new content
	if (emby) {
		emby.each{ instance ->
			log.fine "FROM custom amc - Notify Emby: $instance"
			tryLogCatch {
				refreshEmbyLibrary(instance.host, 8096, instance.token)
			}
		}
	}

	// mark episodes as 'acquired'
	if (myepisodes) {
		log.fine 'FROM custom amc - Update MyEpisodes'
		tryLogCatch {
			executeScript('update-mes', [login:myepisodes.join(':'), addshows:true], getRenameLog().values())
		}
	}

	if (pushover) {
		log.fine 'FROM custom amc - Sending Pushover notification'
		tryLogCatch {
			Pushover(pushover[0], pushover[1] ?: 'wcckDz3oygHSU2SdIptvnHxJ92SQKK').send(getNotificationTitle(), getNotificationMessage())
		}
	}

	// messages used for email / pushbullet reports
	def getReportSubject = { getNotificationMessage('', ' | ') }
	def getReportTitle = { '[FileBot] ' + getReportSubject() }
	def getReportMessage = {
		def renameLog = getRenameLog()
		'''<!DOCTYPE html>\n''' + XML {
			html {
				head {
					meta(charset:'UTF-8')
					style('''
						p{font-family:Arial,Helvetica,sans-serif}
						p b{color:#07a}
						hr{border-style:dashed;border-width:1px 0 0 0;border-color:lightgray}
						small{color:#d3d3d3;font-size:xx-small;font-weight:normal;font-family:Arial,Helvetica,sans-serif}
						table a:link{color:#666;font-weight:bold;text-decoration:none}
						table a:visited{color:#999;font-weight:bold;text-decoration:none}
						table a:active,table a:hover{color:#bd5a35;text-decoration:underline}
						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}
						table th{padding:15px;border-top:1px solid #fafafa;border-bottom:1px solid #e0e0e0;background:#ededed}
						table th{text-align:center;padding-left:20px}
						table tr:first-child th:first-child{border-top-left-radius:3px}
						table tr:first-child th:last-child{border-top-right-radius:3px}
						table tr{text-align:left;padding-left:20px}
						table td:first-child{text-align:left;padding-left:20px;border-left:0}
						table td{padding:15px;border-top:1px solid #fff;border-bottom:1px solid #e0e0e0;border-left:1px solid #e0e0e0;background:#fafafa;white-space:nowrap}
						table tr.even td{background:#f6f6f6}
						table tr:last-child td{border-bottom:0}
						table tr:last-child td:first-child{border-bottom-left-radius:3px}
						table tr:last-child td:last-child{border-bottom-right-radius:3px}
						table tr:hover td{background:#f2f2f2}
					''')
					title(getReportTitle())
				}
				body {
					p {
						mkp.yield("FileBot finished processing ")
						b(getReportSubject())
						mkp.yield(" (${renameLog.size()} files).")
					}
					hr(); table {
						tr { th('Original Name'); th('New Name'); th('New Location') }
						renameLog.each{ from, to ->
							tr { [from.name, to.name, to.parent].each{ cell -> td(cell) } }
						}
					}
					hr(); small("// Generated by ${Settings.applicationIdentifier} on ${InetAddress.localHost.hostName} at ${now}")
				}
			}
		}
	}

	// store processing report
	if (storeReport) {
		def reportFolder = ApplicationFolder.AppData.resolve('reports')
		def reportName = [now.format(/[yyyy-MM-dd HH mm]/), getReportSubject().take(50)].join(' ').validateFileName().space('_')
		def reportFile = getReportMessage().saveAs(reportFolder.resolve(reportName + '.html'))
		log.finest "FROM custom amc - Saving report as ${reportFile}"
	}

	// send pushbullet report
	if (pushbullet) {
		log.fine 'FROM custom amc - Sending PushBullet report'
		tryLogCatch {
			PushBullet(pushbullet).sendFile(getNotificationTitle(), getReportMessage(), 'text/html', getNotificationMessage(), any{ mailto }{ null })
		}
	}

	// send email report
	if (gmail || mail) {
		tryLogCatch {
			sendEmailReport(getReportTitle(), getReportMessage(), 'text/html')
		}
	}
}


// ---------- CLEAN UP ---------- //


// clean up temporary files that may be left behind after extraction
if (deleteAfterExtract) {
	extractedArchives.each{ a ->
		log.finest "FROM custom amc - Delete archive $a"
		a.delete()
		a.dir.listFiles().toList().findAll{ v -> v.name.startsWith(a.nameWithoutExtension) && v.extension ==~ /r\d+/ }.each{ v ->
			log.finest "FROM custom amc - Delete archive volume $v"
			v.delete()
		}
	}
}

// clean empty folders, clutter files, etc after move
if (clean) {
	if (['DUPLICATE', 'COPY', 'HARDLINK'].any{ it.equalsIgnoreCase(_args.action) } && temporaryFiles.size() > 0) {
		log.fine 'FROM custom amc - Clean temporary extracted files'
		// delete extracted files
		temporaryFiles.findAll{ it.isFile() }.sort().each{
			log.finest "FROM custom amc - Delete $it"
			it.delete()
		}
		// delete remaining empty folders
		temporaryFiles.findAll{ it.isDirectory() }.sort().reverse().each{
			log.finest "FROM custom amc - Delete $it"
			if (it.getFiles().size() == 0) {
				it.deleteDir()
			}
		}
	}

	// deleting remaining files only makes sense after moving files
	if ('MOVE'.equalsIgnoreCase(_args.action)) {
		def cleanerInput = args.size() > 0 ? args : ut.kind == 'multi' && ut.dir ? [ut.dir as File] : []
		cleanerInput = cleanerInput.findAll{ f -> f.exists() }
		if (cleanerInput.size() > 0) {
			log.fine 'FROM custom amc - Clean clutter files and empty folders'
			executeScript('cleaner', args.size() == 0 ? [root:true, ignore: ignore] : [root:false, ignore: ignore], cleanerInput)
		}
	}
}



if (destinationFiles.size() == 0) {
	fail "FROM custom amc - Finished without processing any files"
}
Last edited by kim on 16 Nov 2017, 19:26, edited 4 times in total.
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

add this to the end of SOMETHING.groovy e.g. "amc_custom.groovy"

Code: Select all

/************
***HTPC***
********** */

/**
 * Kodi helper functions
 */
def scanVideoLibrary(host, port) {
	def json = [jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1]
	postKodiRPC(host, port, json)
}

def showNotification(host, port, title, message, image) {
	def json = [jsonrpc:'2.0', method:'GUI.ShowNotification', params: [title: title, message: message, image: image], id: 1]
	postKodiRPC(host, port, json)
}

def postKodiRPC(host, port, json) {
	def url = "http://$host:$port/jsonrpc"
	def data = JsonOutput.toJson(json)

	log.finest "POST: $url $data"
	new URL(url).post(data.getBytes('UTF-8'), 'application/json', [:])
}



/**
 * Plex helpers
 */
def refreshPlexLibrary(server, port, token) {
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = "$protocol://$server:$port/library/sections/all/refresh"
	if (token) {
		url += "?X-Plex-Token=$token"
	}
	log.finest "GET: $url"
	new URL(url).get()
}



/**
 * Emby helpers
 */
def refreshEmbyLibrary(server, port, token) {
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = "$protocol://$server:$port/Library/Refresh"
	if (token) {
		url += "?api_key=$token"
	}
	log.finest "POST: $url"
	new URL(url).post([:], [:])
}



/**
 * Sonarr helpers
 */
def rescanSonarrSeries(server, port, apikey, seriesId) {
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = new URL("$protocol://$server:$port")
	def requestHeader = ['X-Api-Key': apikey]

	def series = new JsonSlurper().parseText(new URL(url, '/api/series').get(requestHeader).text)
	def id = series.find{ it.tvdbId == seriesId }?.id

	def command = [name: 'rescanSeries', seriesId: id]
	new URL(url, '/api/command').post(JsonOutput.toJson(command).getBytes('UTF-8'), 'application/json', requestHeader)
}



/**
 * Sickbeard helpers
 */
def rescanSickbeardSeries(server, port, apikey, seriesId) {
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = "$protocol://$server:$port/api/$apikey?cmd=show.refresh&tvdbid=$seriesId"
	log.finest "GET: $url"
	new URL(url).get()
}


/** * TheTVDB artwork/nfo helpers */
def fetchSeriesBanner(outputFile, seriesId, bannerType, bannerType2, season, override, locale) {
	if (outputFile.exists() && !override) {
		log.finest "FROM custom htpc fetchSeriesBanner - Banner already exists: [$outputFile] / [$bannerType:$bannerType2]"
		return outputFile
	}

	// select and fetch banner
	def artwork = TheTVDB.getArtwork(seriesId, bannerType, locale)
	def banner = [locale.language, null].findResult { lang -> artwork.find{ it.matches(bannerType2, season, lang) } }
	if (banner == null) {
		log.finest "FROM custom htpc fetchSeriesBanner - Banner not found: [$outputFile] / [$bannerType:$bannerType2]"
		return null
	}
	log.finest "FROM custom htpc fetchSeriesBanner - Downloading: $banner => [$outputFile]"
	return banner.url.saveAs(outputFile)
}

def fetchEpisodeThumb(outputFile, EpisodePicUrl, override, locale) {
	if (outputFile.exists() && !override) {
		log.finest "FROM custom htpc fetchEpisodeThumb - EpisodeThumbFile already exists: [$outputFile]"
		return outputFile
	}
	log.finest "FROM custom htpc fetchEpisodeThumb - Downloading: [$EpisodePicUrl] => [$outputFile]"
	return EpisodePicUrl.saveAs(outputFile)
}

def fetchSeriesFanart(outputFile, seriesId, type, season, override, locale) {
	if (outputFile.exists() && !override) {
		log.finest "FROM custom htpc fetchSeriesFanart - Fanart already exists: [$outputFile]"
		return outputFile
	}

	def artwork = FanartTV.getArtwork(seriesId, "tv", locale)
	def fanart = [locale.language, null].findResult{ lang -> artwork.find{ it.matches(type, season, lang) } }
	if (fanart == null) {
		log.finest "FROM custom htpc fetchSeriesFanart - Fanart not found: [$outputFile] / [$type]"
		return null
	}
	log.finest "FROM custom htpc fetchSeriesFanart - Downloading: $fanart => [$outputFile]"
	return fanart.url.saveAs(outputFile)
}

def fetchSeriesNfo(outputFile, i, override, locale, nfotype, tvshowFile, sxe, episodeThumbfile, seasonNumber, episodeNumber, episodeId) {

	// Get list of actors
	if (nfotype == 'tvshow') {log.fine "FROM custom htpc fetchSeriesNfo - Downloading list of Actors"}

	def TheTVDBv2 = new TheTVDBClient(/YOUR_KEY_HERE/)
	def ActorsJson = TheTVDBv2.requestJson("series/${i.id}/actors", locale, Cache.ONE_MONTH)

	def dateAndTime = new Date().format('yyyy-MM-dd HH:mm:ss')

	def xml = XML {
		if (nfotype == 'tvshow') {
		log.finest "FROM custom htpc fetchSeriesNfo tvshow - Generate Series NFO: [$i.name] / ID: [$i.id]"
			tvshow {
				title(i.name)
				if (i.aliasNames[0]) { Aliases((i.aliasNames).join(' | ')) }
				sorttitle([i.name, i.startDate as String].findAll{ it?.length() > 0 }.findResults{ it.sortName('$2') }.join('::'))
				rating(i.rating)
				votes(i.ratingCount)
				year(i.startDate?.year)
				airsday(i.airsDayOfWeek)
				airstime(i.airsTime)
				plot(i.overview)
				runtime(i.runtime)
				thumb(aspect:"banner", i.bannerUrl)
				mpaa(i.certification)
				episodeguide {
					url(cache:"${i.id}.xml", "https://www.thetvdb.com/api/1D62F2F90030C444/series/${i.id}/all/${locale.language}.zip")
				}
				id(i.id)
				i.genres.each{
					genre(it)
				}
				premiered(i.startDate)
				status(i.status)
				studio(i.network)
				ActorsJson.data.each{ a ->
					actor {
						name((a.name).toString().trim())
						role(a.role)
						order(a.sortOrder)
						if (a.image != '') {
							thumb("https://www.thetvdb.com/banners/${a.image}")
						}
					}
				}
				imdb(id:i.imdbId, "http://www.imdb.com/title/" + i.imdbId)
				tvdb(id:i.id, "https://www.thetvdb.com/?tab=series&id=${i.id}")
				dateadded(dateAndTime)
			}
		}
		if (nfotype == 'fileNamedotNFO') {
			def mi = tryLogCatch{ tvshowFile ? MediaInfo.snapshot(tvshowFile) : null }

			episodeId.each{

				// Get English Episodes info
				def EpisodeJson = TheTVDBv2.requestJson("episodes/${it}", locale, Cache.ONE_WEEK)
				def data = EpisodeJson.data
				def EpisodePicUrl = ''

				// Download Episode-Thumb.jpg
				if (data.filename != ''){
					EpisodePicUrl = new URL ("https://www.thetvdb.com/banners/${data.filename}")
					fetchEpisodeThumb(episodeThumbfile, EpisodePicUrl, override, locale)
				}

				log.fine "FROM custom htpc fetchSeriesNfo fileNamedotNFO - Generate Episode NFO: [$i.name] / Name: [$data.episodeName] / SxE: [$sxe] / ID: [$it]"

				episodedetails {
					title(data.episodeName)
					showtitle(i.name)
					rating(data.siteRating)
					season(data.airedSeason)
					episode(data.airedEpisodeNumber)
					if (data.dvdSeason != null && data.dvdEpisodeNumber != null){
						seasondvd(data.dvdSeason)
						episodedvd(data.dvdEpisodeNumber)
					}
					uniqueid(data.id)
					votes(data.siteRatingCount)
					if (data.overview != null) {
							plot((data.overview).trim())
					}
					runtime(i.runtime)
					if (EpisodePicUrl != ''){
						thumb(EpisodePicUrl)
					}
					mpaa(i.certification)
					i.genres.each{
						genre(it)
					}
					if (data.writers != '') {
						(data.writers).each{
							credits(it)
						}
					}
					if (data.director != '') {
						(data.director).tokenize('|').each{ d ->
							director(d)
						}
					}
					premiered(i.startDate)
					aired(data.firstAired)
					studio(i.network)
					(data.guestStars).each{ g ->
						actor {
							name(g)
							role("Guest star")
						}
					}
					ActorsJson.data.each{ a ->
						actor {
							name((a.name).toString().trim())
							role(a.role)
							order(a.sortOrder)
							if (a.image != '') {
								thumb("https://www.thetvdb.com/banners/${a.image}")
							}
						}
					}
					fileinfo {
						streamdetails {
							mi?.each { kind, streams ->
								def section = kind.toString().toLowerCase()
								streams.each { s ->
									if (section == 'general') {
										general {
											if (s.'FileName' != null) {
												filename((s.'FileName').trim())
											}
											if (s.'FileExtension' != null) {
												fileextension((s.'FileExtension').trim())
											}
											if (s.'FileSize' != null) {
												filesize((s.'FileSize').trim())
											}
											if (s.'FileSize' != null) {
												FileSizeinMB(((s.'FileSize').toBigDecimal()/1024/1024).round(2))
											}
											if (s.'Title' != null) {
												title((s.'Title').trim())
											}
											if (s.'Encoded_Date' != null) {
												encoded((s.'Encoded_Date').trim())
											}
											if (s.'Duration/String3' != null) {
												durationinhmsms((s.'Duration/String3').trim())
											}
											if (s.'OverallBitRate' != null) {
												overallbitrate( Math.round((s.'OverallBitRate' as BigDecimal)/1000) )
											}
											if (s.'Cover' != null) {
												Cover(s.'Cover')
												if (s.'Attachments' != null) {
													Attachments(s.'Attachments')
												}
											}
										}
									}

									if (section == 'video') {
										video {
											codec((s.'CodecID/Hint' ?: s.'CodecID' ? (s.'CodecID'.contains('avc1') || s.'CodecID'.contains('xvid')) ? s.'CodecID'.replaceAll(/xvid/,'XviD') : s.'InternetMediaType'.contains('video/H264') ? 'h264' : s.'Encoded_Library_Name' ?: s.'Format' : s.'Codec').replaceAll(/\s\w{3,}/).space('').trim())
											if (s.'Duration' != null) {
												BigDecimal durationINms = new BigDecimal(s.'Duration')
												durationinseconds(Math.round((durationINms)/1000))
												durationinminutes(Math.round((durationINms)/60000))
											}
											if (s.'DisplayAspectRatio' != null) {
												aspect((s.'DisplayAspectRatio').trim())
											}
											if (s.'Width' != null) {
												width((s.'Width').trim())
											}
											if (s.'Height' != null) {
												height((s.'Height').trim())
											}
											if (s.'ScanType' != null) {
												scantype((s.'ScanType').trim())
											}
											if (s.'BitRate' != null) {
												bitrate( Math.round((s.'BitRate' as BigDecimal)/1000) )
											}
											if (s.'FrameRate' != null) {
												framerate((s.'FrameRate').trim())
											}
											if (s.'StreamSize' != null) {
												streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2))
											}
										}
									}

									if (section == 'audio') {
										audio {
											codec((s.'CodecID/Hint' ?: s.'Codec'.contains('DTS-HD') ? s.'Codec' : s.'Format').replaceAll(/\p{Punct}/, '').trim())
											if (s.'Format_Profile' != null){
												codecformatprofile((s.'Format_Profile').replaceAll(/\s\/\s/, '|').replaceAll(/\|Core|\|LC|\|TrueHD/, '').trim().replaceAll(/\s/, '_'))
											}
											if (s.'Language/String3' != null) {
												language((s.'Language/String3').trim())
											}
											else{language('und')}
											if (s.'Language/String' != null) {
												longlanguage((s.'Language/String').trim())
											}
											else{longlanguage('undefined')}
											if (s.'Channel(s)' != null) {
												if (s.'Channel(s)_Original' != null){
													channels((s.'Channel(s)_Original').trim())
												}
												else {channels((s.'Channel(s)').replaceAll(/Object\sBased\s\//, '')*.split(' / ')*.max().max())}
											}
											if (s.'BitRate' != null) {
												bitrate( (s.'BitRate').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
											}
											if (s.'BitRate' == null && s.'BitRate_Maximum' != null) {
												bitrate( (s.'BitRate_Maximum').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
											}
											if (s.'SamplingRate' != null) {
												samplingrateinkhz(s.'SamplingRate'.replaceAll(/^\s\/\s/, '').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
											}
											if (s.'StreamSize' != null) {
												streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2))
											}
										}
									}

									if (section == 'text') {
										subtitle {
											if (s.'Language/String3' != null) {
												language(s.'Language/String3')
											}
											if (s.'Language/String' != null) {
												longlanguage((s.'Language/String').replaceAll(/\s/, '').trim())
											}
											if (s.'Title' != null) {
												title((s.'Title').trim())
											}
											if (s.'Format' != null) {
												format((s.'Format').trim())
											}
											if (s.'Forced' != null) {
												forced((s.'Forced').trim())
											}
											if (s.'StreamSize' != null && s.'StreamSize' != '0') {
												streamSizeinKB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1024).round(2))
											}
										}
									}
								}
							}
						}
					}
					if (data.imdbId != ''){
						imdb(id:data.imdbId, "https://www.imdb.com/title/${data.imdbId}")
					}
					tvdb(id:data.id, "https://www.thetvdb.com/?tab=episode&id=${data.id}")
					dateadded(dateAndTime)
				}
			}
		}
	}
	xml.saveAs(outputFile)
	log.finest "FROM custom htpc fetchSeriesNfo - Saving NFO: [$outputFile]"
}

def fetchSeriesNfoTMDB(outputFile, i, override, locale, nfotype, tvshowFile, sxe, episodeThumbfile, seasonNumber, episodeNumber, episodeId, seriesDir, seasonDir) {

	def slurper = new groovy.json.JsonSlurper()
	def baseURL = "https://api.themoviedb.org/3/tv/"
	def tmdbAPIKey = "?api_key=YOUR_KEY_HERE"
	def imgBase = "https://image.tmdb.org/t/p"
	def imgOrg = "/original"
	def imgPre = "/w300"
	def addOns = "&append_to_response=videos,external_ids,credits"
	def langUS = "&language=en-US"
	def posterPath = new File("$seriesDir/folder.jpg")
	def backdropPath = new File("$seriesDir/fanart.jpg")
	def seasonPath = new File("$seasonDir/folder.jpg")
	def URL seasonPoster

	def dateAndTime = new Date().format('yyyy-MM-dd HH:mm:ss')

	def xml = XML {
		if (nfotype == 'tvshow') {
			def seriesInfoUrl = "${baseURL}${i.id}${tmdbAPIKey}${langUS}${addOns}"
			def seriesInfo = slurper.parseText( new URL( seriesInfoUrl ).getText('UTF-8') )

			def extIds = seriesInfo.external_ids
			def URL seriesposter = new URL("$imgBase$imgOrg${seriesInfo.poster_path}")
			def URL seriesbackdrop = new URL("$imgBase$imgOrg${seriesInfo.backdrop_path}")
			log.fine "FROM custom htpc fetchSeriesNfoTMDB - seriesInfoUrl: [$seriesInfoUrl]"

			seriesInfo.seasons.each{ p ->
				if (p.season_number == seasonNumber){
					if (p.poster_path != null) {
						seasonPoster = new URL("$imgBase$imgOrg${p.poster_path}")
						if (seasonPath.exists() && !override) {
							log.finest "FROM custom htpc fetchSeriesNfoTMDB - Fanart already exists: [$seasonPath]"
						}
						else{seasonPoster.saveAs(seasonPath)}
					}
				}
			}

			if (posterPath.exists() && !override) {
				log.finest "FROM custom htpc fetchSeriesNfoTMDB - Fanart already exists: [$posterPath]"
			}
			else{seriesposter.saveAs(posterPath)}

			if (backdropPath.exists() && !override) {
				log.finest "FROM custom htpc fetchSeriesNfoTMDB - Fanart already exists: [$backdropPath]"
			}
			else{seriesbackdrop.saveAs(backdropPath)}

			log.finest "FROM custom htpc fetchSeriesNfoTMDB tvshow - Generate Series NFO: [$i.name] / ID: [$i.id]"

			tvshow {
				title(i.name)
				if (i.aliasNames[0]) { Aliases((i.aliasNames).join(' | ')) }
				sorttitle([i.name, i.startDate as String].findAll{ it?.length() > 0 }.findResults{ it.sortName('$2') }.join('::'))
				rating(i.rating)
				votes(i.ratingCount)
				year(i.startDate?.year)
				plot(seriesInfo.overview)
				runtime(i.runtime)
				thumb(aspect:"poster", type:"season", season:"-1", seriesposter)
				thumb(aspect:"fanart", seriesbackdrop)
				mpaa(i.certification)
				id(i.id)
				i.genres.each{
					it.split(' & ').each{
						genre(it.replaceAll(/Sci-Fi/,'Science Fiction'))}
				}
				premiered(i.startDate)
				status(i.status)
				studio(i.network)
				seriesInfo.credits.cast.each{ a ->
					actor {
						name((a.name).toString().trim())
						role(a.character)
						order(a.order)
						if (a.profile_path != null) {
							thumb(preview:"$imgBase$imgPre${a.profile_path}", "$imgBase$imgOrg${a.profile_path}")
						}
					}
				}
				if (extIds.imdb_id != null){
					imdb(id:extIds.imdb_id, "https://www.imdb.com/title/${extIds.imdb_id}")
				}
				if (extIds.tvdb_id != null){
					tvdb(id:extIds.tvdb_id, "https://www.thetvdb.com/?tab=series&id=${extIds.tvdb_id}")
				}
				tmdb(id:i.id, "https://www.themoviedb.org/tv/${i.id}")
				dateadded(dateAndTime)
			}
		}

		if (nfotype == 'fileNamedotNFO') {
			def mi = tryLogCatch{ tvshowFile ? MediaInfo.snapshot(tvshowFile) : null }

			episodeNumber.each{ epNumber ->
				log.fine "FROM custom htpc fetchSeriesNfoTMDB fileNamedotNFO - Processing episodeId: [$episodeId]"

				// Get English Episodes info
				def episodeInfoUrl = "${baseURL}${i.id}/season/${seasonNumber}/episode/${epNumber}${tmdbAPIKey}${langUS}${addOns}"
				def episodeInfo = slurper.parseText( new URL( episodeInfoUrl ).getText('UTF-8') )
				log.fine "FROM custom htpc fetchSeriesNfoTMDB - episodeInfoUrl: [$episodeInfoUrl]"

				def extEids = episodeInfo.external_ids
				def EpisodePicUrl = ''

				// Download Episode-Thumb.jpg
				if (episodeInfo.still_path != null){
					EpisodePicUrl = new URL ("$imgBase$imgOrg${episodeInfo.still_path}")
					fetchEpisodeThumb(episodeThumbfile, EpisodePicUrl, override, locale)
				}

				episodedetails {
					title(episodeInfo.name)
					showtitle(i.name)
					rating(episodeInfo.vote_average)
					season(episodeInfo.season_number)
					episode(episodeInfo.episode_number)
					uniqueid(episodeInfo.id)
					votes(episodeInfo.vote_count)
					if (episodeInfo.overview != '') {
							plot((episodeInfo.overview).trim())
					}
					runtime(i.runtime)
					if (EpisodePicUrl != ''){
						thumb(preview:"$imgBase$imgPre${episodeInfo.still_path}", EpisodePicUrl)
					}
					mpaa(i.certification)
					i.genres.each{
						it.split(' & ').each{
							genre(it.replaceAll(/Sci-Fi/,'Science Fiction'))}
					}
					episodeInfo.crew.each{ p ->
						if (p.job == 'Director') {
							director(p.name)
						} else if (p.department == 'Writing') {
							credits("$p.name ($p.job)")
						}
					}
					premiered(i.startDate)
					aired(episodeInfo.air_date)
					studio(i.network)
					episodeInfo.guest_stars.each{ g ->
						actor {
							name(g.name)
							role("${g.character} (guest star)")
							if (g.profile_path != null) {
								thumb(preview:"$imgBase$imgPre${g.profile_path}", "$imgBase$imgOrg${g.profile_path}")
							}

						}
					}
					episodeInfo.credits.cast.each{ a ->
						actor {
							name((a.name).toString().trim())
							role(a.character)
							order(a.order)
							if (a.profile_path != null) {
								thumb(preview:"$imgBase$imgPre${a.profile_path}", "$imgBase$imgOrg${a.profile_path}")
							}
						}
					}
					fileinfo {
						streamdetails {
							mi?.each { kind, streams ->
								def section = kind.toString().toLowerCase()
								streams.each { s ->
									if (section == 'general') {
										general {
											if (s.'FileName' != null) {
												filename((s.'FileName').trim())
											}
											if (s.'FileExtension' != null) {
												fileextension((s.'FileExtension').trim())
											}
											if (s.'FileSize' != null) {
												filesize((s.'FileSize').trim())
											}
											if (s.'FileSize' != null) {
												FileSizeinMB(((s.'FileSize').toBigDecimal()/1024/1024).round(2))
											}
											if (s.'Title' != null) {
												title((s.'Title').trim())
											}
											if (s.'Encoded_Date' != null) {
												encoded((s.'Encoded_Date').trim())
											}
											if (s.'Duration/String3' != null) {
												durationinhmsms((s.'Duration/String3').trim())
											}
											if (s.'OverallBitRate' != null) {
												overallbitrate( Math.round((s.'OverallBitRate' as BigDecimal)/1000) )
											}
											if (s.'Cover' != null) {
												Cover(s.'Cover')
												if (s.'Attachments' != null) {
													Attachments(s.'Attachments')
												}
											}
										}
									}

									if (section == 'video') {
										video {
											codec((s.'CodecID/Hint' ?: s.'CodecID' ? (s.'CodecID'.contains('avc1') || s.'CodecID'.contains('xvid')) ? s.'CodecID'.replaceAll(/xvid/,'XviD') : s.'InternetMediaType'.contains('video/H264') ? 'h264' : s.'Encoded_Library_Name' ?: s.'Format' : s.'Codec').replaceAll(/\s\w{3,}/).space('').trim())
											if (s.'Duration' != null) {
												BigDecimal durationINms = new BigDecimal(s.'Duration')
												durationinseconds(Math.round((durationINms)/1000))
												durationinminutes(Math.round((durationINms)/60000))
											}
											if (s.'DisplayAspectRatio' != null) {
												aspect((s.'DisplayAspectRatio').trim())
											}
											if (s.'Width' != null) {
												width((s.'Width').trim())
											}
											if (s.'Height' != null) {
												height((s.'Height').trim())
											}
											if (s.'ScanType' != null) {
												scantype((s.'ScanType').trim())
											}
											if (s.'BitRate' != null) {
												bitrate( Math.round((s.'BitRate' as BigDecimal)/1000) )
											}
											if (s.'FrameRate' != null) {
												framerate((s.'FrameRate').trim())
											}
											if (s.'StreamSize' != null) {
												streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2))
											}
										}
									}

									if (section == 'audio') {
										audio {
											codec((s.'CodecID/Hint' ?: s.'Codec'.contains('DTS-HD') ? s.'Codec' : s.'Format').replaceAll(/\p{Punct}/, '').trim())
											if (s.'Format_Profile' != null){
												codecformatprofile((s.'Format_Profile').replaceAll(/\s\/\s/, '|').replaceAll(/\|Core|\|LC|\|TrueHD/, '').trim().replaceAll(/\s/, '_'))
											}
											if (s.'Language/String3' != null) {
												language((s.'Language/String3').trim())
											}
											else{language('und')}
											if (s.'Language/String' != null) {
												longlanguage((s.'Language/String').trim())
											}
											else{longlanguage('undefined')}
											if (s.'Channel(s)' != null) {
												if (s.'Channel(s)_Original' != null){
													channels((s.'Channel(s)_Original').trim())
												}
												else {channels((s.'Channel(s)').replaceAll(/Object\sBased\s\//, '')*.split(' / ')*.max().max())}
											}
											if (s.'BitRate' != null) {
												bitrate( (s.'BitRate').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
											}
											if (s.'BitRate' == null && s.'BitRate_Maximum' != null) {
												bitrate( (s.'BitRate_Maximum').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
											}
											if (s.'SamplingRate' != null) {
												samplingrateinkhz(s.'SamplingRate'.replaceAll(/^\s\/\s/, '').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
											}
											if (s.'StreamSize' != null) {
												streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2))
											}
										}
									}

									if (section == 'text') {
										subtitle {
											if (s.'Language/String3' != null) {
												language(s.'Language/String3')
											}
											if (s.'Language/String' != null) {
												longlanguage((s.'Language/String').replaceAll(/\s/, '').trim())
											}
											if (s.'Title' != null) {
												title((s.'Title').trim())
											}
											if (s.'Format' != null) {
												format((s.'Format').trim())
											}
											if (s.'Forced' != null) {
												forced((s.'Forced').trim())
											}
											if (s.'StreamSize' != null && s.'StreamSize' != '0') {
												streamSizeinKB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1024).round(2))
											}
										}
									}
								}
							}
						}
					}
				if (extEids.imdb_id != null){
					imdb(id:extEids.imdb_id, "https://www.imdb.com/title/${extEids.imdb_id}")
				}
				if (extEids.tvdb_id != null){
					tvdb(id:extEids.tvdb_id, "https://www.thetvdb.com/?tab=episode&id=${extEids.tvdb_id}")
				}
				tmdb(id:episodeInfo.id, "https://www.themoviedb.org/tv/${i.id}/season/${seasonNumber}/episode/${epNumber}")
				dateadded(dateAndTime)
				}
			}
		}
	}
	xml.saveAs(outputFile)
	log.finest "FROM custom htpc fetchSeriesNfoTMDB - Saving NFO: [$outputFile]"
}

def fetchSeriesArtworkAndNfo(seriesDir, seasonDir, seriesId, season, override, locale, tvshowFile = null, episodeNumber, sxe, EpisodeOrder = _args.order, episodeId, extras = false, tvDB) {
	log.fine "FROM custom htpc fetchSeriesArtworkAndNfo - Season: [$season] / SeasonDir: [$seasonDir] / SeriesId: [$seriesId]"
	log.fine "FROM custom htpc fetchSeriesArtworkAndNfo - Episode(s): [$episodeNumber] / SxE: [$sxe] / EpisodeId: [$episodeId]"
	log.fine "FROM custom htpc fetchSeriesArtworkAndNfo - EpisodeOrder: [$EpisodeOrder]"

	tryLogCatch {

		// fetch nfo
		def seriesInfo
		def String fname = tvshowFile.name
		def String fileNamedotNFO = fname.nameWithoutExtension+'.nfo'

		def episodePicExt = '-thumb.jpg'
		def String fileNameThumb = tvshowFile.nameWithoutExtension+episodePicExt
		def episodeThumbfile = seasonDir.resolve(fileNameThumb)

		if (tvDB == 'TheMovieDB::TV'){
			log.finest "FROM custom htpc fetchSeriesArtworkAndNfo - TheMovieDB::TV MODE"
			seriesInfo = TheMovieDB_TV.getSeriesInfo(seriesId, locale)
			fetchSeriesNfoTMDB(seriesDir.resolve('tvshow.nfo'), seriesInfo, override, locale, 'tvshow', tvshowFile, sxe, null, season, episodeNumber, episodeId, seriesDir, seasonDir)
			fetchSeriesNfoTMDB(seasonDir.resolve(fileNamedotNFO), seriesInfo, override, locale, 'fileNamedotNFO', tvshowFile, sxe, episodeThumbfile, season, episodeNumber, episodeId, seriesDir, seasonDir)
		}

		else {
			log.finest "FROM custom htpc fetchSeriesArtworkAndNfo - TheTVDB MODE"
			seriesInfo = TheTVDB.getSeriesInfo(seriesId, locale)
			fetchSeriesNfo(seriesDir.resolve('tvshow.nfo'), seriesInfo, override, locale, 'tvshow', tvshowFile, sxe, null, season, episodeNumber, episodeId)
			fetchSeriesNfo(seasonDir.resolve(fileNamedotNFO), seriesInfo, override, locale, 'fileNamedotNFO', tvshowFile, sxe, episodeThumbfile, season, episodeNumber, episodeId)

				// fetch season banners
			if (seasonDir != seriesDir) {
				fetchSeriesBanner(seasonDir.resolve('folder.jpg'), seriesId, 'season', 'season', season, override, locale)
				fetchSeriesBanner(seasonDir.resolve('banner.jpg'), seriesId, 'seasonwide', 'seasonwide', season, override, locale)
			}

			// fetch series banner, fanart, posters, etc
			['680x1000', null].findResult{ fetchSeriesBanner(seriesDir.resolve('folder.jpg'), seriesId, 'poster', it, null, override, locale) }
			['graphical', null].findResult{ fetchSeriesBanner(seriesDir.resolve('banner.jpg'), seriesId, 'series', it, null, override, locale) }

			// fetch highest resolution fanart
			['1920x1080', '1280x720', null].findResult{ fetchSeriesBanner(seriesDir.resolve('fanart.jpg'), seriesId, 'fanart', it, null, override, locale) }

		}

		 // e.g season13-poster.jpg
		def String seasonPadnumber = season.pad(2)
		def seasonposter = "season$seasonPadnumber-poster.jpg"

		// folder image (resuse series poster if possible)
		copyIfPossible(seasonDir.resolve('folder.jpg'), seriesDir.resolve(seasonposter))

		// folder image (resuse series poster if possible)
		copyIfPossible(seriesDir.resolve('fanart.jpg'), seriesDir.resolve('season-all-fanart.jpg'))
		copyIfPossible(seriesDir.resolve('folder.jpg'), seriesDir.resolve('season-all-poster.jpg'))

		// download extras files
		if (extras) {
			// fetch fanart
			['hdclearart', 'clearart'].findResult{ type -> fetchSeriesFanart(seriesDir.resolve('clearart.png'), seriesId, type, null, override, locale) }
			['hdtvlogo', 'clearlogo'].findResult{ type -> fetchSeriesFanart(seriesDir.resolve('logo.png'), seriesId, type, null, override, locale) }
			fetchSeriesFanart(seriesDir.resolve('landscape.jpg'), seriesId, 'tvthumb', null, override, locale)

			// fetch season fanart
			if (seasonDir != seriesDir) {
				fetchSeriesFanart(seasonDir.resolve('landscape.jpg'), seriesId, 'seasonthumb', season, override, locale)
			}
		}
	}
}

/** * TheMovieDB artwork/nfo helpers */
def fetchMovieArtwork(outputFile, movieInfo, category, override, locale) {

	// select and fetch artwork
	if (outputFile.exists() && !override) {
		log.finest "FROM custom htpc fetchMovieArtwork - Artwork already exists: [$outputFile]"
		return outputFile
	}

	def artwork = TheMovieDB.getArtwork(movieInfo.id, category, locale)

		if (category == 'posters') {
			def selection = [locale.language, 'en', 'xx', null].findResult{ lang -> artwork.find{ it.matches(lang) } }
			if (selection == null) {
				log.finest "FROM custom htpc fetchMovieArtwork posters - Artwork not found: [$outputFile]"
				return null
			}
			log.finest "FROM custom htpc fetchMovieArtwork posters - Downloading poster $selection => [$outputFile]"
			return selection.url.saveAs(outputFile)
		}
		if (category == 'backdrops') {
			def selection = [null, 'null', locale.language, 'en', 'xx'].findResult{ lang -> artwork.find{ it.matches(lang) } }
			if (selection == null) {
				log.finest "FROM custom htpc fetchMovieArtwork backdrops - Artwork not found: [$outputFile]"
				return null
			}
			log.finest "FROM custom htpc fetchMovieArtwork backdrops - Downloading fanart $selection => [$outputFile]"
			return selection.url.saveAs(outputFile)
		}
}

def fetchAllMovieArtwork(outputFolder, prefix, movieInfo, category, override, locale) {	/*** Only if extras is y (true) ***/
	// select and fetch artwork
	def artwork = TheMovieDB.getArtwork(movieInfo.id, category, locale)
	def selection = [locale.language, 'en', null].findResults{ lang -> artwork.findAll{ it.matches(lang) } }.flatten().unique()
	if (selection == null) {
		log.finest "FROM custom htpc fetchAllMovieArtwork - Artwork not found: [$outputFolder]"
		return null
	}
	selection.eachWithIndex{ s, i ->
		def outputFile = new File(outputFolder, "${prefix}${i+1}.jpg")
		if (outputFile.exists() && !override) {
			log.finest "FROM custom htpc fetchAllMovieArtwork - Artwork already exists: [$outputFile]"
		} else {
			log.finest "FROM custom htpc fetchAllMovieArtwork - Downloading: $s => [$outputFile]"
			s.url.saveAs(outputFile)
		}
	}
}

def fetchMovieFanart(outputFile, movieInfo, type, override, locale) {	/*** Only if extras is y (true) ***/
	if (outputFile.exists() && !override) {
		log.finest "FROM custom htpc fetchMovieFanart - Fanart already exists: [$outputFile]"
		return outputFile
	}

	def artwork = FanartTV.getArtwork(movieInfo.id, "movies", locale)
	def fanart = [locale, null].findResult{ lang -> artwork.find{ it.matches(type, lang) } }
	if (fanart == null) {
		log.finest "FROM custom htpc fetchMovieFanart - Fanart not found: File: [$outputFile.name] / Type: [$type]"
		return null
	}
	log.finest "FROM custom htpc fetchMovieFanart - Downloading: $fanart => [$outputFile]"
	return fanart.url.saveAs(outputFile)
}

def fetchMovieNfo(outputFile, i, movieFile, fileset) {
	log.finest "FROM custom htpc fetchMovieNfo - Generate Movie NFO: [$i.name] / ID [$i.id]"

	// Used for checksum and avg. of e.g. bitrate
	def filesetSIZE = fileset.size()
	def filesetCounter = 0

	// MediaInfo - Generate from all files in fileset
	def mis = tryLogCatch{fileset ? fileset.findResults{MediaInfo.snapshot(it)} : null }
	def filesizeinBYTES, overallBITRATE, durationinMS, videoBITRATE

	if (mis != null && filesetSIZE >= 2){
		log.finest "FROM custom htpc fetchMovieNfo - Multi-part movie, getting data for 'fileinfo-summary'"
		// MediaInfo - Get General values for fileinfo-summary
		filesizeinBYTES = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[0]['FileSize'].join('')) } ?: null}
			catch (Exception e) {log.warning "FROM custom htpc fetchMovieNfo - Warning: General FileSize missing, used for filesize and filesizetotal under fileinfo-summary"}
		}
		overallBITRATE = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[0]['OverallBitRate'].join('')) } ?: null}
			catch (Exception e) {log.warning "FROM custom htpc fetchMovieNfo - Warning: General OverallBitRate missing, used for overallbitrate and overallbitrateavg under fileinfo-summary"}
		}
		durationinMS = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[0]['Duration'].join('')) } ?: null}
			catch (Exception e) {log.warning "FROM custom htpc fetchMovieNfo - Warning: General Duration missing, used for durationinhmsms and durationinminutestotal under fileinfo-summary"}
		}

		// MediaInfo - Get Video values for fileinfo-summary
		videoBITRATE = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[1]['BitRate'].join('')) } ?: null}
			catch (NumberFormatException | Exception e) {log.warning "FROM custom htpc fetchMovieNfo - Warning: Video BitRate missing, used for bitrate and bitrateavg under fileinfo-summary"}
		}
	}

	// Get [filtered] Alternative Titles, only if any found
	def CustomAltLIST = ''
	if (i.alternativeTitles[0] != null){
		def altCountryLIST = ['AU', 'CA', 'DK', 'GB', 'NO', 'SE', 'US']
		log.fine "FROM custom htpc fetchMovieNfo - Downloading Alternative Titles for these languages: $altCountryLIST"
		CustomAltLIST = TheMovieDB.getAlternativeTitles(i.id).findAll{key, value -> altCountryLIST.any{key.contains(it)}}.sort().findResults{key, value -> value.findResults{"$it ($key)"} }.flatten()
	}

	def certCountryLIST = ['DK', 'NO', 'SE', 'US']
	def CustomCertLIST = i.certifications.findAll{key, value -> certCountryLIST.any{key.contains(it)}}.sort().findResults{key, value -> value + ' ('+key+')'}

	def KODI_Trailer = "plugin://plugin.video.youtube/?action=play_video&videoid="
	def Youtube_Video = "https://www.youtube.com/watch?v="
	def dateAndTime = new Date().format('yyyy-MM-dd HH:mm:ss')

	def xml = XML {
		movie {
			id('tt' + (i.imdbId ?: 0).pad(7))
			title(i.name)
			originaltitle(i.originalName)
			CustomAltLIST.each{
				alternativetitles(it)
			}
			sorttitle([i.collection, i.name, i.released as String].findAll{ it?.length() > 0 }.findResults{ it.sortName('$2') }.join('::'))
			set(i.collection)
			i.productionCompanies.each{
				studio(it)
			}
			if (i.adult) { genre('Adult') }
			i.genres.each{
				genre(it.trim())
			}
			year(i.released?.year)
			premiered(i.released)
			runtime(i.runtime)
			rating(i.rating)
			votes(i.votes)
			tagline(i.tagline)
			mpaa((CustomCertLIST).join(' / '))
			plot(i.overview)
			i.people.each{ p ->
				if (p.director) {
					director(p.name)
				} else if (p.job ==~ /Producer/) {
					producer(p.name)
				} else if (p.actor) {
					actor {
						name((p.name).trim())
						role((p.character).replaceAll(/\s+/, ' ').trim())
						order(p.order)
						if (p.image != null) {
							thumb(p.image)
						}
					}
				} else if (!p.actor && !p.department) {
					actor {
						name((p.name).trim())
						if (p.character != null) {
							role((p.character).replaceAll(/\s+/, ' ').trim())
						}
						order(p.order)
						if (p.image != null) {
							thumb(p.image)
						}
					}
 				} else if (p.department == 'Writing') {
					credits("$p.name ($p.job)")
				}
			}
			i.productionCountries.each{
				country(it)
			}
			i.spokenLanguages.each{
				languages(it)
			}
			i.trailers.each{
				if (it.type == 'Trailer'){
					trailer(name:it.name, KODI_Trailer+it.key)
				}
			}
			i.trailers.each{
				if (it.type == 'Trailer'){
					youtube(Youtube_Video+it.key)
				}
			}

			mis.each { f ->
				fileinfo {
				streamdetails {
					f.each { kind, streams ->
						def section = kind.toString().toLowerCase()
						streams.each { s ->
							if (section == 'general') {
								general {
									if (s.'FileName' != null) {
										filename((s.'FileName').trim())
									}
									if (s.'FileExtension' != null) {
										fileextension((s.'FileExtension').trim())
									}
									if (s.'FileSize' != null) {
										filesize((s.'FileSize').trim())
									}
									if (s.'FileSize' != null) {
										FileSizeinMB(((s.'FileSize').toBigDecimal()/1024/1024).round(2))
									}
									if (s.'Title' != null) {
										title((s.'Title').trim())
									}
									if (s.'Encoded_Date' != null) {
										encoded((s.'Encoded_Date').trim())
									}
									if (s.'Duration/String3' != null) {
										durationinhmsms((s.'Duration/String3').trim())
									}
									if (s.'OverallBitRate' != null) {
										overallbitrate( Math.round((s.'OverallBitRate' as BigDecimal)/1000) )
									}
									if (s.'Cover' != null) {
										Cover(s.'Cover')
										if (s.'Attachments' != null) {
											Attachments(s.'Attachments')
										}
									}
									if (filesetCounter < filesetSIZE){
										// Get filehash
										checksum(computeHash(fileset[filesetCounter]))
										filesetCounter++
									}
								}
							}

							if (section == 'video') {
								video {
									codec((s.'CodecID/Hint' ?: s.'CodecID' ? (s.'CodecID'.contains('avc1') || s.'CodecID'.contains('xvid')) ? s.'CodecID'.replaceAll(/xvid/,'XviD') : s.'InternetMediaType'.contains('video/H264') ? 'h264' : s.'Encoded_Library_Name' ?: s.'Format' : s.'Codec').replaceAll(/\s\w{3,}/).space('').trim())
									if (s.'Duration' != null) {
										BigDecimal durationINms = new BigDecimal(s.'Duration')
										durationinseconds(Math.round((durationINms)/1000))
										durationinminutes(Math.round((durationINms)/60000))
									}
									if (s.'DisplayAspectRatio' != null) {
										aspect((s.'DisplayAspectRatio').trim())
									}
									if (s.'Width' != null) {
										width((s.'Width').trim())
									}
									if (s.'Height' != null) {
										height((s.'Height').trim())
									}
									if (s.'ScanType' != null) {
										scantype((s.'ScanType').trim())
									}
									if (s.'BitRate' != null) {
										bitrate(Math.round((s.'BitRate' as BigDecimal)/1000))
									}
									if (s.'FrameRate' != null) {
										framerate((s.'FrameRate').trim())
									}
									if (s.'StreamSize' != null) {
										streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2))
									}
								}
							}

							if (section == 'audio') {
								audio {
									codec((s.'CodecID/Hint' ?: s.'Codec'.contains('DTS-HD') ? s.'Codec' : s.'Format').replaceAll(/\p{Punct}/, '').trim())
									if (s.'Format_Profile' != null){
										codecformatprofile((s.'Format_Profile').replaceAll(/\s\/\s/, '|').replaceAll(/\|Core|\|LC|\|TrueHD/, '').trim().replaceAll(/\s/, '_'))
									}
									if (s.'Language/String3' != null) {
										language((s.'Language/String3').trim())
									}
									else{language('und')}
									if (s.'Language/String' != null) {
										longlanguage((s.'Language/String').trim())
									}
									else{longlanguage('undefined')}
									if (s.'Channel(s)' != null) {
										if (s.'Channel(s)_Original' != null){
											channels((s.'Channel(s)_Original').trim())
										}
										else {channels((s.'Channel(s)').replaceAll(/Object\sBased\s\//, '')*.split(' / ')*.max().max())}
									}
									if (s.'BitRate' != null) {
										bitrate( (s.'BitRate').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
									}
									if (s.'BitRate' == null && s.'BitRate_Maximum' != null) {
										bitrate( (s.'BitRate_Maximum').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
									}
									if (s.'SamplingRate' != null) {
										samplingrateinkhz(s.'SamplingRate'.replaceAll(/^\s\/\s/, '').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000)
									}
									if (s.'StreamSize' != null) {
										streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2))
									}
								}
							}

							if (section == 'text') {
								subtitle {
									if (s.'Language/String3' != null) {
										language(s.'Language/String3')
									}
									if (s.'Language/String' != null) {
										longlanguage((s.'Language/String').replaceAll(/\s/, '').trim())
									}
									if (s.'Title' != null) {
										title((s.'Title').trim())
									}
									if (s.'Format' != null) {
										format((s.'Format').trim())
									}
									if (s.'Forced' != null) {
										forced((s.'Forced').trim())
									}
									if (s.'StreamSize' != null && s.'StreamSize' != '0') {
										streamSizeinKB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1024).round(2))
									}
								}
							}
						}
					}
				}
				}
			}

			if (filesetSIZE >= 2) {
				'fileinfo-summary'{
					general {
						if (filesizeinBYTES != null){
							filesizetotal(filesizeinBYTES.sum())
						}
						if (overallBITRATE != null){
							overallbitrateavg(Math.round(overallBITRATE.sum()/1000/filesetSIZE))
						}
					}
					video {
						if (durationinMS != null){
							durationinminutestotal(Math.round(durationinMS.sum()/60000))
						}
						if (videoBITRATE != null){
							bitrateavg(Math.round(videoBITRATE.sum()/1000/filesetSIZE))
						}
					}
				}
			}

			imdb(id:"tt" + (i.imdbId ?: 0).pad(7), "http://www.imdb.com/title/tt" + (i.imdbId ?: 0).pad(7))
			tmdb(id:i.id, "https://www.themoviedb.org/movie/${i.id}")
			dateadded(dateAndTime)
		}
	}
	xml.saveAs(outputFile)
	log.finest "FROM custom htpc fetchMovieNfo - Save Movie NFO to: [$outputFile]"
}

def fetchMovieArtworkAndNfo(movieDir, movie, movieFile = null, extras = false, override, locale, fileset, nfoOnly) {

	tryLogCatch {
		// Removing -Trailer file from fileset/NFO
		fileset = fileset.findAll{ !it.getName().matches(/(?i:.+\-Trailer\.\w{3})/) }.findResults{ it }

		def movieInfo = TheMovieDB.getMovieInfo(movie, locale, true)

		if (nfoOnly){
			// fetch Only nfo
			fetchMovieNfo(movieDir.resolve('movie.nfo'), movieInfo, movieFile, fileset)
		}
		else{
			// fetch nfo
			fetchMovieNfo(movieDir.resolve('movie.nfo'), movieInfo, movieFile, fileset)

			// fetch movie fanart and poster
			fetchMovieArtwork(movieDir.resolve('folder.jpg'), movieInfo, 'posters', override, locale)
			fetchMovieArtwork(movieDir.resolve('fanart.jpg'), movieInfo, 'backdrops', override, locale)

			// generate url files
			if (extras) {
				[[db:'imdb', id:movieInfo.imdbId, url:'http://www.imdb.com/title/tt' + (movieInfo.imdbId ?: 0).pad(7)], [db:'tmdb', id:movieInfo.id, url:"https://www.themoviedb.org/movie/${movieInfo.id}"]].each{
					if (it.id > 0) {
						def content = "[InternetShortcut]\nURL=${it.url}\n"
						content.saveAs(movieDir.resolve("${it.db}.url"))
					}
				}
			}

			// download extras files
			if (extras) {
				['hdmovieclearart', 'movieart'].findResult { type -> fetchMovieFanart(movieDir.resolve('clearart.png'), movieInfo, type, override, locale) }
				['hdmovielogo', 'movielogo'].findResult { type -> fetchMovieFanart(movieDir.resolve('logo.png'), movieInfo, type, override, locale) }
				['bluray', 'dvd', null].findResult { diskType -> fetchMovieFanart(movieDir.resolve('disc.png'), movieInfo, diskType, override, locale) }

				fetchAllMovieArtwork(movieDir.resolve('extrafanart'), 'fanart', movieInfo, 'backdrops', override, locale)

				def SameNameAsMovie = movieFile.nameWithoutExtension+'.jpg'
				copyIfPossible(movieDir.resolve('folder.jpg'), movieDir.resolve(SameNameAsMovie))
				log.finest "Copy and Rename [$movieDir\\folder.jpg] => [$SameNameAsMovie]"
			}

			// folder image (reuse movie poster if possible)
			copyIfPossible(movieDir.resolve('poster.jpg'), movieDir.resolve('folder.jpg'))
		}
	}
}


def copyIfPossible(File src, File dst) {
	if (src.exists() && !dst.exists()) {
		src.copyAs(dst)
	}
}
User avatar
rednoah
The Source
Posts: 22923
Joined: 16 Nov 2011, 08:59
Location: Taipei
Contact:

Re: Unofficial custom AMC script (advanced users only)

Post by rednoah »

I recommend forking the script repository on GitHub to make it easier to maintain your own changes and pulling updates from the upstream repository. ;)
:idea: Please read the FAQ and How to Request Help.
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

Any chance you can tell me:

1. Can I use the internal "Cache" on e.g. this so it can reuse it

Code: Select all

def seriesInfoUrl = "${baseURL}${i.id}${tmdbAPIKey}${langUS}${addOns}"
def seriesInfo = slurper.parseText( new URL( seriesInfoUrl ).getText('UTF-8') )
2. If yes, can you give me a hint, please :D ?

Code: Select all

import static net.filebot.CachedResource.*;
import static net.filebot.CachedResource.fetchIfModified;
import net.filebot.Cache;
import net.filebot.CacheType;
+ something?

3. If no, will you then make a "requestJson" to use on tmdb ?
User avatar
rednoah
The Source
Posts: 22923
Joined: 16 Nov 2011, 08:59
Location: Taipei
Contact:

Re: Unofficial custom AMC script (advanced users only)

Post by rednoah »

You could use this method. That's what's being used internally to send requests:

Code: Select all

net.filebot.web.TMDbClient.request(String resource, Map<String, Object> parameters, Locale locale)
net.filebot.WebServices.TheMovieDB is a public static instance of TMDbClient that you could reuse to make the requests you want.
:idea: Please read the FAQ and How to Request Help.
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

Thx, but can you give an e.g. or dumb it down please ? ;)

now I'm only thinking of using the TV part

net.filebot.web.TMDbClient = only movies ?
net.filebot.web.TMDbTVClient = only tv ?
net.filebot.WebServices.TheMovieDB = only movies ?
request(String resource, Map<String, Object> parameters, Locale locale)
"String resource" = e.g. "tv/"+"141"+"/season/"+"2" ?
"Map<String, Object> parameters" = something with "videos,external_ids,credits" ?

so something like (do not work)

Code: Select all

net.filebot.web.TMDbTVClient.request("tv/141", ["append_to_response":"videos,external_ids,credits"], locale)
I use this

Code: Select all

seriesInfo = TheMovieDB_TV.getSeriesInfo(seriesId, locale)
but this only gets some of the info, I need full access to the API output ?
also from the episodes

Code: Select all

def addOns = "&append_to_response=videos,external_ids,credits"
def episodeInfoUrl = "${baseURL}${i.id}/season/${seasonNumber}/episode/${epNumber}${tmdbAPIKey}${langUS}${addOns}"
def episodeInfo = slurper.parseText( new URL( episodeInfoUrl ).getText('UTF-8') )
e.g.

Code: Select all

episodeInfo.still_path
I have been using net.filebot.WebServices.TheMovieDB for some time e.g.

Code: Select all

{net.filebot.WebServices.TheMovieDB.getAlternativeTitles(movie.id)}
but I don't get what you mean...
can I use both ?
one is better ?
User avatar
rednoah
The Source
Posts: 22923
Joined: 16 Nov 2011, 08:59
Location: Taipei
Contact:

Re: Unofficial custom AMC script (advanced users only)

Post by rednoah »

Like this:

Code: Select all

net.filebot.WebServices.TheMovieDB.request("tv/141", ["append_to_response":"videos,external_ids,credits"], locale)
:idea: Please read the FAQ and How to Request Help.
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

nice, thx... I was so close :)

does this mean, I can't use in e.g. the htpc.groovy ?
"That's what's being used internally to send requests"

Code: Select all

net.filebot.web.TMDbClient.request(String resource, Map<String, Object> parameters, Locale locale)
Last edited by kim on 10 Nov 2017, 16:36, edited 2 times in total.
User avatar
rednoah
The Source
Posts: 22923
Joined: 16 Nov 2011, 08:59
Location: Taipei
Contact:

Re: Unofficial custom AMC script (advanced users only)

Post by rednoah »

I mean that it's an internal API. It's used internally by all the other TMDb methods to make various kinds of requests. But you can call it in your Groovy code as well. The return object should be a nested structure of Map / List / Value objects. It'll take care of caching as well. You should also see all the extra debug logging so you can see your custom requests in the log.


EDIT:

It'll also take care of adding the FileBot API key to the request. So you don't need to worry about that either.
:idea: Please read the FAQ and How to Request Help.
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

sounds cool, can you give an e.g. code for this also please ?
You should also see all the extra debug logging so you can see your custom requests in the log.
It's there a way around this and using the build in key ?

Code: Select all

def TheTVDBv2 = new TheTVDBClient(/YOUR_KEY_HERE/) 
that way there is no need to get your own api keys and edit the file, making is easier to use
User avatar
rednoah
The Source
Posts: 22923
Joined: 16 Nov 2011, 08:59
Location: Taipei
Contact:

Re: Unofficial custom AMC script (advanced users only)

Post by rednoah »

1.
Try making a simple test case (i.e. 1 line) with the information I already gave you, and then show me what you've got and what kind of error you get if it's not working.


EDIT:

I forgot that I already gave you an example yesterday... :lol:

Code: Select all

println net.filebot.WebServices.TheMovieDB.request("tv/141", ["append_to_response":"videos,external_ids,credits"], Locale.ENGLISH)
@see viewtopic.php?f=4&t=5451&p=31081#p31058


2.
net.filebot.WebServices.TheTVDB is a static instance you can use. It's an instance of the v2 client class in newer versions of FileBot.
:idea: Please read the FAQ and How to Request Help.
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

yes, but you made it sound like I can do more with this ?

Code: Select all

net.filebot.web.TMDbClient.request(String resource, Map<String, Object> parameters, Locale locale)
"net.filebot.WebServices.TheTVDB is a static instance you can use. It's an instance of the v2 client class in newer versions of FileBot."
what version are we talking about, so I can write only works from version XXX


btw: I already converted my script to use "net.filebot.WebServices.TheMovieDB.request" :)
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (advanced users only)

Post by kim »

looks like I only needed to change this, is this the correct way ?

from

Code: Select all

def EpisodeJson = TheTVDBv2.requestJson("episodes/${it}", locale, Cache.ONE_WEEK)
to

Code: Select all

def EpisodeJson = net.filebot.WebServices.TheTVDB.requestJson("episodes/${it}", locale, Cache.ONE_WEEK)
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (intermediate users)

Post by kim »

***Version 2***

warning, this is a very modified version of amc+htpc (2in1)

This script now support:
TV episodes from both thetvdb and themoviedb (Kodi style + a lot more info)
Movies (Kodi style + a lot more info)

Use like official AMC, but you may need a local copy of this:
https://github.com/filebot/scripts/blob ... ner.groovy

To get TV from themoviedb:
--def tmdbTV=y (default is use thetvdb)

Other new/changed stuff:
--def nfoOnly=y (get/make ONLY Movie/TV NFO's)
--def extras=y

sorry but "The maximum number of allowed characters is 60000."
so I most split it up in 2 posts

copy and save this as SOMETHING.groovy e.g. "amc_custom.groovy"

AMC (part 1):

Code: Select all

#!/usr/bin/env filebot -script

import static groovy.json.StringEscapeUtils.*
import static net.filebot.web.OpenSubtitlesHasher.*

def formatType = []
_args.args.each{ n -> n =~ /=@/ ? formatType << n :'' }
def override = _args.conflict.equalsIgnoreCase('override')
def logFile = any{_args.logFile }{ 'No log file' }
def locale = any{ _args.language.locale }{ Locale.US }
def nfoOnly = tryQuietly{ nfoOnly.toBoolean() }
def tmdbTV = tryQuietly{ tmdbTV.toBoolean() }
def tvDB = tmdbTV ? 'TheMovieDB::TV' : 'TheTVDB'

// log input parameters
log.info("FROM custom amc - Run script [$_args.script] at [$now]")
log.info("FROM custom amc - Log file [$logFile]")
log.info "FROM custom amc - tvDB: [${tvDB}]"
log.info "FROM custom amc - EpisodeOrder: [${_args.order}]"
log.info("FROM custom amc - Parameter: Language = [$locale]")
log.info("FROM custom amc - Parameter: Override = [$override]")
_def.each{ n, v -> log.finest('FROM custom amc - Parameter: ' + [n, n =~ /plex|kodi|pushover|pushbullet|mail|myepisodes|.+Format/ ? n =~ /.+Format/ ? formatType : '*****' : v].join(' = ')) }
args.withIndex().each{ f, i -> if (f.exists()) { log.finest "FROM custom amc - Argument[$i]: $f" } else { log.warning "FROM custom amc - Argument[$i]: Does not exist: $f" } }

// initialize variables
failOnError = _args.conflict.equalsIgnoreCase('fail')
testRun = _args.action.equalsIgnoreCase('test')

// --output folder must be a valid folder
outputFolder = tryLogCatch{ any{ _args.output }{ '.' }.toFile().getCanonicalFile() }

// enable/disable features as specified via --def parameters
unsorted  = tryQuietly{ unsorted.toBoolean() }
music     = tryQuietly{ music.toBoolean() }
subtitles = tryQuietly{ subtitles.split(/\W+/) as List }
artwork   = tryQuietly{ artwork.toBoolean() && !testRun }
extras    = tryQuietly{ extras.toBoolean() }
clean     = tryQuietly{ clean.toBoolean() }
exec      = tryQuietly{ exec.toString() }

// array of kodi/plex/emby hosts
kodi = tryQuietly{ any{kodi}{xbmc}.split(/[ ,;|]+/)*.split(/:(?=\d+$)/).collect{ it.length >= 2 ? [host: it[0], port: it[1] as int] : [host: it[0]] } }
plex = tryQuietly{ plex.split(/[ ,;|]+/)*.split(/:/).collect{ it.length >= 2 ? [host: it[0], token: it[1]] : [host: it[0]] } }
emby = tryQuietly{ emby.split(/[ ,;|]+/)*.split(/:/).collect{ it.length >= 2 ? [host: it[0], token: it[1]] : [host: it[0]] } }

// extra options, myepisodes updates and email notifications
extractFolder      = tryQuietly{ extractFolder as File }
skipExtract        = tryQuietly{ skipExtract.toBoolean() }
deleteAfterExtract = tryQuietly{ deleteAfterExtract.toBoolean() }
excludeList        = tryQuietly{ def f = excludeList as File; f.isAbsolute() ? f : outputFolder.resolve(f.path) }
myepisodes         = tryQuietly{ myepisodes.split(':', 2) as List }
gmail              = tryQuietly{ gmail.split(':', 2) as List }
mail               = tryQuietly{ mail.split(':', 5) as List }
pushover           = tryQuietly{ pushover.split(':', 2) as List }
pushbullet         = tryQuietly{ pushbullet.toString() }
storeReport        = tryQuietly{ storeReport.toBoolean() }
reportError        = tryQuietly{ reportError.toBoolean() }

// user-defined filters
label       = any{ _args.mode }{ ut_label }{ null }
ignore      = any{ ignore }{ null }
minFileSize = any{ minFileSize.toLong() }{ 50 * 1000L * 1000L }
minLengthMS = any{ minLengthMS.toLong() }{ 10 * 60 * 1000L }

// series/anime/movie format expressions
seriesFormat   = any{ seriesFormat   }{ '{plex}' }
animeFormat    = any{ animeFormat    }{ '{plex}' }
movieFormat    = any{ movieFormat    }{ '{plex}' }
musicFormat    = any{ musicFormat    }{ '{plex}' }
unsortedFormat = any{ unsortedFormat }{ 'Unsorted/{file.structurePathTail}' }



// force Movie / TV Series / Anime behaviour
def forceMovie(f) {
	label =~ /^(?i:Movie|Film|Concert|UFC)/ || f.dir.listPath().any{ it.name ==~ /(?i:Movies|Movie)/ } || f.isMovie() || any{ f.isVideo() && !forceSeries(f) && getMediaInfo(f, '{minutes}').toInteger() >= 100 }{ false }
}

def forceSeries(f) {
	label =~ /^(?i:TV|Show|Series|Documentary)/ || f.dir.listPath().any{ it.name ==~ /(?i:TV.Shows|TV.Series|TV)/ } || f.path =~ /(?<=\b|_)(?i:tv[sp]-|Season\D?\d{1,2}|\d{4}.S\d{2})(?=\b|_)/ || parseEpisodeNumber(f.path, true) || parseDate(f.path)
}

def forceAnime(f) {
	label =~ /^(?i:Anime)/ || f.dir.listPath().any{ it.name ==~ /(?i:Anime)/ } || (f.isVideo() && (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 }))
}

def forceAudio(f) {
	label =~ /^(?i:audio|music|music.video)/ || (f.isAudio() && !f.isVideo())
}

def forceIgnore(f) {
	label =~ /^(?i:games|ebook|other|ignore)/
}



// include artwork/nfo, pushover/pushbullet and ant utilities as required
// if (artwork || kodi || plex || emby) { include('lib/htpc') }
if (pushover || pushbullet ) { include('lib/web') }
if (gmail || mail) { include('lib/ant') }



// error reporting functions
def sendEmailReport(title, message, messagetype) {
	if (gmail) {
		sendGmail(
			subject: title, message: message, messagemimetype: messagetype,
			to: any{ mailto } { gmail[0].contains('@') ? gmail[0] : gmail[0] + '@gmail.com' },		// mail to self by default
			user: gmail[0].contains('@') ? gmail[0] : gmail[0] + '@gmail.com', password: gmail[1]
		)
	}
	if (mail) {
		sendmail(
			subject: title, message: message, messagemimetype: messagetype,
			mailhost: mail[0], mailport: mail[1], from: mail[2], to: mailto,
			user: mail[3], password: mail[4]
		)
	}
}

def fail(message) {
	if (reportError) {
		sendEmailReport('[FileBot] Failure', message as String, 'text/plain')
	}
	die(message)
}



// check input parameters
def ut = _def.findAll{ k, v -> k.startsWith('ut_') }.collectEntries{ k, v ->
	if (v ==~ /[%$]\p{Alnum}|\p{Punct}+/) {
		log.warning "FROM custom amc - Bad $k value: $v"
		v = null
	}
	return [k.substring(3), v ? v : null]
}



// sanity checks
if (outputFolder == null || !outputFolder.isDirectory()) {
	fail "Illegal usage: output folder must exist and must be a directory: $outputFolder"
}

if (ut.dir) {
	if (ut.state_allow && !(ut.state ==~ ut.state_allow)) {
		fail "Illegal state: $ut.state != $ut.state_allow"
	}
	if (args.size() > 0) {
		fail "Illegal usage: use either script parameters $ut or file arguments $args but not both"
	}
	if (ut.dir == '/') {
		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."
	}
	if (ut.dir.toFile() in outputFolder.listPath()) {
		fail "Illegal usage: output folder [$outputFolder] must be separate from input folder $ut"
	}
} else if (args.size() == 0) {
	fail "Illegal usage: no input"
} else if (args.any{ f -> f in outputFolder.listPath() }) {
	fail "Illegal usage: output folder [$outputFolder] must be separate from input arguments $args"
} else if (args.any{ f -> f in File.listRoots() }) {
	fail "Illegal usage: input $args must not include a filesystem root"
}



// collect input fileset as specified by the given --def parameters
roots = args

if (args.size() == 0) {
	// assume we're called with utorrent parameters (account for older and newer versions of uTorrents)
	if (ut.kind == 'single' || (ut.kind != 'multi' && ut.dir && ut.file)) {
		roots = [new File(ut.dir, ut.file).getCanonicalFile()] // single-file torrent
	} else {
		roots = [new File(ut.dir).getCanonicalFile()] // multi-file torrent
	}
}

// helper function to work with the structure relative path rather than the whole absolute path
def relativeInputPath(f) {
	def r = roots.find{ r -> f.path.startsWith(r.path) && r.isDirectory() && f.isFile() }
	if (r != null) {
		return f.path.substring(r.path.length() + 1)
	}
	return f.name
}



// define and load exclude list (e.g. to make sure files are only processed once)
excludePathSet = new FileSet()

if (excludeList) {
	if (excludeList.exists()) {
		try {
			excludePathSet.load(excludeList)
		} catch(Exception e) {
			fail "Failed to load excludeList: $e"
		}
		log.fine "FROM custom amc - Use excludes: $excludeList (${excludePathSet.size()})"
	} else {
		log.fine "FROM custom amc - Use excludes: $excludeList"
		if ((!excludeList.parentFile.isDirectory() && !excludeList.parentFile.mkdirs()) || (!excludeList.isFile() && !excludeList.createNewFile())) {
			fail "Failed to create excludeList: $excludeList"
		}
	}
}


extractedArchives = []
temporaryFiles = []

def extract(f) {
	def folder = new File(extractFolder ?: f.dir, f.nameWithoutExtension)
	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) ?: []

	extractedArchives += f
	temporaryFiles += folder
	temporaryFiles += files

	return files
}


def acceptFile(f) {
	if (f.isHidden()) {
		log.finest "FROM custom amc - Ignore hidden: $f"
		return false
	}

	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/) {
		log.finest "FROM custom amc - Ignore system path: $f"
		return false
	}

	if (f.name =~ /(?<=\b|_)(?i:Sample|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|_)/) {
		log.finest "FROM custom amc - Ignore extra: $f"
		return false
	}

	// ignore if the user-defined ignore pattern matches
	if (f.path.findMatch(ignore)) {
		log.finest "FROM custom amc - Ignore pattern: $f"
		return false
	}

	// ignore archives that are on the exclude path list
	if (excludePathSet.contains(f)) {
		return false
	}

	// accept folders right away and skip file sanity checks
	if (f.isDirectory()) {
		return true
	}

	// accept archives if the extract feature is enabled
	if (f.isArchive() || f.hasExtension('001')) {
		return !skipExtract
	}

	// ignore iso images that do not contain a video disk structure
	if (f.hasExtension('iso') && !f.isDisk()) {
		log.fine "FROM custom amc - Ignore disk image: $f"
		return false
	}

	// ignore small video files
	if (minFileSize > 0 && f.isVideo() && f.length() < minFileSize) {
		log.fine "FROM custom amc - Skip small video file: $f"
		return false
	}

	// ignore short videos
	if (minLengthMS > 0 && f.isVideo() && any{ getMediaInfo(f, '{minutes}').toLong() * 60 * 1000L < minLengthMS }{ false /* default if MediaInfo fails */ }) {
		log.fine "FROM custom amc - Skip short video: $f"
		return false
	}

	// ignore subtitle files without matching video file in the same or parent folder
	if (f.isSubtitle() && ![f, f.dir].findResults{ it.dir }.any{ it.listFiles{ it.isVideo() && f.isDerived(it) }}) {
		log.fine "FROM custom amc - Ignore orphaned subtitles: $f"
		return false
	}

	// process only media files (accept audio files only if music mode is enabled)
	return f.isVideo() || f.isSubtitle() || (music && f.isAudio())
}


// 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)
def resolveInput(f) {
	// resolve folder recursively, except disk folders
	if (f.isDirectory()) {
		if (f.isDisk()) {
			return f
		}
		return f.listFiles{ acceptFile(it) }.collect{ resolveInput(it) }
	}

	if (f.isArchive() || f.hasExtension('001')) {
		return extract(f).findAll{ acceptFile(it) }.collect{ resolveInput(it) }
	}

	return f
}



// flatten nested file structure
def input = roots.findAll{ acceptFile(it) }.flatten{ resolveInput(it) }.toSorted()

// update exclude list with all input that will be processed during this run
if (excludeList && !testRun) {
	excludePathSet.append(excludeList, extractedArchives, input)
}

// print exclude and input sets for logging
input.each{ log.fine "FROM custom amc - Input: $it" }

// early abort if there is nothing to do
if (input.size() == 0) {
	log.warning "FROM custom amc - No files selected for processing"
	return
}


// group episodes/movies and rename according to Plex standards
def groups = input.groupBy{ f ->
	// print xattr metadata
	if (f.metadata) {
		log.finest "FROM custom amc groups - xattr: [$f.name] => [$f.metadata]"
	}

	// skip auto-detection if possible
	if (forceIgnore(f))
		return []
	if (music && forceAudio(f)) // process audio only if music mode is enabled
		return [music: f.dir.name]
	if (forceMovie(f))
		return [mov:   detectMovie(f, false)]
	if (forceSeries(f))
		return [tvs:   detectSeriesName(f) ?: detectSeriesName(input.findAll{ s -> f.dir == s.dir && s.isVideo() })]
	if (forceAnime(f))
		return [anime: detectAnimeName(f) ?: detectAnimeName(input.findAll{ s -> f.dir == s.dir && s.isVideo() })]


	def tvs = detectSeriesName(f)
	def mov = detectMovie(f, false)
	log.fine "FROM custom amc - $f.name [series: $tvs, movie: $mov]"

	// DECIDE EPISODE VS MOVIE (IF NOT CLEAR)
	if (tvs && mov) {
		log.fine "FROM custom amc groups - DECIDE EPISODE VS MOVIE (IF NOT CLEAR)"
		def norm = { s -> s.ascii().normalizePunctuation().lower().space(' ') }
		def dn = norm(guessMovieFolder(f)?.name ?: '')
		log.fine "FROM custom amc groups - DECIDE EPISODE VS MOVIE:  [$dn] [MediaDetection.java]"
		def fn = norm(f.nameWithoutExtension)
		def sn = norm(tvs)
		def mn = norm(mov.name)
		def my = mov.year as String

		// S00E00 | 2012.07.21 | One Piece 217 | Firefly - Serenity | [Taken 1, Taken 2, Taken 3, Taken 4, ..., Taken 10]
		def metrics = [
			[tvs: -1, mov:  0, fun: { mn == fn } ],
			[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 } } ],
			[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 } } ],
			[tvs:  5, mov: -1, fun: { parseEpisodeNumber(fn, true) || parseDate(fn) } ],
			[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 } ],
			[tvs:  1, mov: -1, fun: { fn.after(sn) ==~ /.{0,3}\s-\s.+/ && matchMovie(fn) == null } ],
			[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 } ],
			[tvs: -1, mov:  1, fun: { fn =~ /tt\d{7}/ } ],
			[tvs: -1, mov:  1, fun: { f.nameWithoutExtension ==~ /[\D\s_.]+/ } ],
			[tvs: -1, mov:  5, fun: { detectMovie(f, true) && [dn, fn].find{ it =~ /(19|20)\d{2}/ } != null } ],
			[tvs: -1, mov:  1, fun: { fn.contains(mn) && parseEpisodeNumber(fn.after(mn), false) == null } ],
			[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 } ],
			[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 } ],
			[tvs: -1, mov:  1, fun: { detectMovie(f, false).aliasNames.find{ fn.contains(norm(it)) } } ]
		]

		def score = [tvs: 0, mov: 0]
		metrics.each{
			if (tvs && mov && it.fun()) {
				score.tvs += it.tvs
				score.mov += it.mov

				if (score.tvs >= 1 && score.mov <= -1) {
					log.fine "FROM custom amc - Exclude Movie: $mov"
					mov = null
				} else if (score.mov >= 1 && score.tvs <= -1) {
					log.fine "FROM custom amc - Exclude Series: $tvs"
					tvs = null
				}
			}
		}

	}

	// CHECK CONFLICT
	if (((mov && tvs) || (!mov && !tvs))) {
		if (failOnError) {
			fail 'Media detection failed'
		} else {
			log.fine "FROM custom amc - Unable to differentiate: [$f.name] => [$tvs] VS [$mov]"
			return [:]
		}
	}

	return [tvs: tvs, mov: mov]
}

// group entries by unique tvs/mov descriptor
groups = groups.groupBy{ group, files -> group.collectEntries{ type, query -> [type, query ? query.toString().ascii().normalizePunctuation().lower() : null] } }.collectEntries{ group, maps -> [group, maps.values().flatten()] }

// log movie/series/anime detection results
groups.each{ group, files -> log.finest "FROM custom amc Group: $group => ${files*.name}" }

// keep track of files that have been processed successfully
def destinationFiles = []

// keep track of unsorted files or files that could not be processed for some reason
def unsortedFiles = []

// process each batch
groups.each{ group, files ->
	// fetch subtitles (but not for anime)
	if (group.anime == null && subtitles != null && files.findAll{ it.isVideo() }.size() > 0) {
		subtitles.each{ languageCode ->
			def subtitleFiles = getMissingSubtitles(file: files, lang: languageCode, strict: true, output: 'srt', encoding: 'UTF-8', format: 'MATCH_VIDEO_ADD_LANGUAGE_TAG') ?: []
			files += subtitleFiles
			input += subtitleFiles // make sure subtitles are added to the exclude list and other post processing operations
			temporaryFiles += subtitleFiles // if downloaded for temporarily extraced files delete later
		}
	}

	// EPISODE MODE
	if ((group.tvs || group.anime) && !group.mov) {
		// choose series / anime

		def dest = group.tvs ? rename(file: files, format: seriesFormat, db: tvDB) : rename(file: files, format: animeFormat, db: 'AniDB')

		if (dest != null) {
			destinationFiles += dest

			if (artwork) {
				dest.mapByFolder().each{ dir, fs ->
					def hasSeasonFolder = any{ dir =~ /Specials|Season.\d+/ || dir.parentFile.structurePathTail.listPath().size() > 0 }{ false }	// MAY NOT WORK FOR CERTAIN FORMATS

					fs.each{epFile ->
						def tvInfo = epFile.metadata
						if (tvInfo instanceof MultiEpisode){
							log.fine "FROM custom amc Episode Mode artwork - FileName: ${epFile}"
							log.fine "FROM custom amc Episode Mode artwork - MultiEpisode: ${tvInfo} / Ids: ${tvInfo.episodes.id}"
							log.fine "FROM custom amc Episode Mode artwork - Fetching series artwork for [$tvInfo.seriesName] / Season: [${tvInfo.special ? 0 : tvInfo.season}] / Episodes: ${tvInfo.special ? tvInfo.episodes.special : tvInfo.episodes.episode} / Titles: $tvInfo.episodes.title to [$dir]"
							fetchSeriesArtworkAndNfo(hasSeasonFolder ? dir.parentFile : dir, dir, tvInfo.seriesInfo.id, tvInfo.special ? 0 : tvInfo.season, override, locale, epFile, tvInfo.special ? tvInfo.episodes.special : tvInfo.episodes.episode, tvInfo.toString().matchAll(/\d{1,2}x\d{2}/), _args.order, tvInfo.episodes ? tvInfo.episodes.id : tvInfo.id, extras, nfoOnly, tvDB)
						}
						else {
							log.fine "FROM custom amc Episode Mode artwork - FileName: [${epFile}]"
							log.fine "FROM custom amc Episode Mode artwork - Episode: [${tvInfo}] / Id: [${tvInfo.id}]"
							log.fine "FROM custom amc Episode Mode artwork - Fetching series artwork for [$tvInfo.seriesName] / Season: [${tvInfo.special ? 0 : tvInfo.season}] / Episode: [${tvInfo.special ? tvInfo.special : tvInfo.episode}] / Title: [$tvInfo.title] to [$dir]"
							fetchSeriesArtworkAndNfo(hasSeasonFolder ? dir.parentFile : dir, dir, tvInfo.seriesInfo.id, tvInfo.special ? 0 : tvInfo.season, override, locale, epFile, tvInfo.special ? tvInfo.special : tvInfo.episode, tvInfo.special ? 0 + 'x' + tvInfo.special.pad(2) : tvInfo.season + 'x' + tvInfo.episode.pad(2), _args.order, tvInfo.id, extras, nfoOnly, tvDB)
						}
					}

				}
			}
		} else if (failOnError) {
			fail "Failed to process group: $group"
		} else {
			unsortedFiles += files
		}
	}

	// MOVIE MODE
	else if (group.mov && !group.tvs && !group.anime) {
		def trailerFile = files.findAll{ it.getName().matches(/(?i:.+\-Trailer\.\w{3})/) }.findResults{ it }
		files = files.findAll{ !it.getName().matches(/(?i:.+\-Trailer\.\w{3})/) }.findResults{ it }
		if (trailerFile.size() != 0) {log.fine "FROM custom amc Movie Mode - trailerFile: $trailerFile"}
		def dest = rename(file: files, format: movieFormat, db: 'TheMovieDB')
		if (trailerFile && dest){
			trailerFile.each{
				def ext = it.toString().after(/-trailer/)
				def newTrailerName = dest.nameWithoutExtension.join()+"-trailer"+ext
				log.fine "FROM custom amc Movie Mode - Rename Trailer File: [${it.getName()}] to [${newTrailerName}]"
				log.fine "FROM custom amc Movie Mode - Moving Trailer File: [${newTrailerName}] to ${dest.parentFile}"
				it.renameTo(new File(dest.parentFile.join(), newTrailerName))
			}
		}

		if (dest != null) {
			destinationFiles += dest

			if (artwork) {
				dest.mapByFolder().each{ dir, fs ->
					def movieFiles = fs.findAll{ it.isVideo() || it.isDisk() }.sort().findResults{ it }
					log.fine "FROM custom amc Movie Mode artwork - movieFiles: $movieFiles"

					def movieFile = fs.findAll{ it.isVideo() || it.isDisk() }.sort{ it.length() }.reverse().findResult{ it }
					if (movieFile) {
						def movieInfo = movieFile.metadata
						log.fine "FROM custom amc Movie Mode - Fetching movie NFO and artwork for [$movieInfo] to [$dir]"
						fetchMovieArtworkAndNfo(dir, movieInfo, movieFile, extras, override, locale, movieFiles, nfoOnly)
					}
				}
			}
		} else if (failOnError) {
			fail "Failed to process group: $group"
		} else {
			unsortedFiles += files
		}
	}

	// MUSIC MODE
	else if (group.music) {
		def dest = rename(file: files, format: musicFormat, db: 'ID3')

		if (dest != null) {
			destinationFiles += dest
		} else if (failOnError) {
			fail "Failed to process group: $group"
		} else {
			unsortedFiles += files
		}
	}

	// UNSORTED
	else {
		unsortedFiles += files
	}
}


// ---------- POST PROCESSING ---------- //

// deal with remaining files that cannot be sorted automatically
if (unsorted) {
	if (unsortedFiles.size() > 0) {
		log.fine "FROM custom amc - Processing ${unsortedFiles.size()} unsorted files"

		def dest = rename(map: unsortedFiles.collectEntries{ original ->
			def destination = getMediaInfo(original, unsortedFormat) as File

			// sanity check user-defined unsorted format
			if (destination == null) {
				fail("Illegal usage: unsorted format must yield valid file path")
			}

			// resolve relative paths
			if (!destination.isAbsolute()) {
				destination = outputFolder.resolve(destination.path)
			}

			return [original, destination]
		})

		if (dest != null) {
			destinationFiles += dest
		}
	}
}

// run program on newly processed files
if (exec) {
	destinationFiles.collect{ getMediaInfo(it, exec) }.unique().each{ command ->
		log.fine "FROM custom amc - Execute: $command"
		execute(command)
	}
}


// ---------- REPORTING ---------- //


if (getRenameLog().size() > 0) {
	// messages used for kodi / plex / emby pushover notifications
	def getNotificationTitle = {
		def count = getRenameLog().count{ k, v -> !v.isSubtitle() }
		return "FileBot finished processing $count files"
	}.memoize()

	def getNotificationMessage = { prefix = '• ', postfix = '\n' ->
		return ut.title ?: (input.findAll{ !it.isSubtitle() } ?: input).collect{ relativeInputPath(it) as File }.root.nameWithoutExtension.unique().collect{ prefix + it }.join(postfix).trim()
	}.memoize()

	// make Kodi scan for new content and display notification message
	if (kodi) {
		kodi.each{ instance ->
			log.fine "FROM custom amc - Notify Kodi: $instance"
			tryLogCatch {
				showNotification(instance.host, instance.port ?: 8080, getNotificationTitle(), getNotificationMessage(), 'https://app.filebot.net/icon.png')
				scanVideoLibrary(instance.host, instance.port ?: 8080)
			}
		}
	}

	// make Plex scan for new content
	if (plex) {
		plex.each{ instance ->
			log.fine "FROM custom amc - Notify Plex: $instance"
			tryLogCatch {
				refreshPlexLibrary(instance.host, 32400, instance.token)
			}
		}
	}

	// make Emby scan for new content
	if (emby) {
		emby.each{ instance ->
			log.fine "FROM custom amc - Notify Emby: $instance"
			tryLogCatch {
				refreshEmbyLibrary(instance.host, 8096, instance.token)
			}
		}
	}

	// mark episodes as 'acquired'
	if (myepisodes) {
		log.fine 'FROM custom amc - Update MyEpisodes'
		tryLogCatch {
			executeScript('update-mes', [login:myepisodes.join(':'), addshows:true], getRenameLog().values())
		}
	}

	if (pushover) {
		log.fine 'FROM custom amc - Sending Pushover notification'
		tryLogCatch {
			Pushover(pushover[0], pushover[1] ?: 'wcckDz3oygHSU2SdIptvnHxJ92SQKK').send(getNotificationTitle(), getNotificationMessage())
		}
	}

	// messages used for email / pushbullet reports
	def getReportSubject = { getNotificationMessage('', ' | ') }
	def getReportTitle = { '[FileBot] ' + getReportSubject() }
	def getReportMessage = {
		def renameLog = getRenameLog()
		'''<!DOCTYPE html>\n''' + XML {
			html {
				head {
					meta(charset:'UTF-8')
					style('''
						p{font-family:Arial,Helvetica,sans-serif}
						p b{color:#07a}
						hr{border-style:dashed;border-width:1px 0 0 0;border-color:lightgray}
						small{color:#d3d3d3;font-size:xx-small;font-weight:normal;font-family:Arial,Helvetica,sans-serif}
						table a:link{color:#666;font-weight:bold;text-decoration:none}
						table a:visited{color:#999;font-weight:bold;text-decoration:none}
						table a:active,table a:hover{color:#bd5a35;text-decoration:underline}
						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}
						table th{padding:15px;border-top:1px solid #fafafa;border-bottom:1px solid #e0e0e0;background:#ededed}
						table th{text-align:center;padding-left:20px}
						table tr:first-child th:first-child{border-top-left-radius:3px}
						table tr:first-child th:last-child{border-top-right-radius:3px}
						table tr{text-align:left;padding-left:20px}
						table td:first-child{text-align:left;padding-left:20px;border-left:0}
						table td{padding:15px;border-top:1px solid #fff;border-bottom:1px solid #e0e0e0;border-left:1px solid #e0e0e0;background:#fafafa;white-space:nowrap}
						table tr.even td{background:#f6f6f6}
						table tr:last-child td{border-bottom:0}
						table tr:last-child td:first-child{border-bottom-left-radius:3px}
						table tr:last-child td:last-child{border-bottom-right-radius:3px}
						table tr:hover td{background:#f2f2f2}
					''')
					title(getReportTitle())
				}
				body {
					p {
						mkp.yield("FileBot finished processing ")
						b(getReportSubject())
						mkp.yield(" (${renameLog.size()} files).")
					}
					hr(); table {
						tr { th('Original Name'); th('New Name'); th('New Location') }
						renameLog.each{ from, to ->
							tr { [from.name, to.name, to.parent].each{ cell -> td(cell) } }
						}
					}
					hr(); small("// Generated by ${Settings.applicationIdentifier} on ${InetAddress.localHost.hostName} at ${now}")
				}
			}
		}
	}

	// store processing report
	if (storeReport) {
		def reportFolder = ApplicationFolder.AppData.resolve('reports')
		def reportName = [now.format(/[yyyy-MM-dd HH mm]/), getReportSubject().take(50)].join(' ').validateFileName().space('_')
		def reportFile = getReportMessage().saveAs(reportFolder.resolve(reportName + '.html'))
		log.finest "FROM custom amc - Saving report as ${reportFile}"
	}

	// send pushbullet report
	if (pushbullet) {
		log.fine 'FROM custom amc - Sending PushBullet report'
		tryLogCatch {
			PushBullet(pushbullet).sendFile(getNotificationTitle(), getReportMessage(), 'text/html', getNotificationMessage(), any{ mailto }{ null })
		}
	}

	// send email report
	if (gmail || mail) {
		tryLogCatch {
			sendEmailReport(getReportTitle(), getReportMessage(), 'text/html')
		}
	}
}


// ---------- CLEAN UP ---------- //

// clean up temporary files that may be left behind after extraction
if (deleteAfterExtract) {
	extractedArchives.each{ a ->
		log.finest "FROM custom amc - Delete archive $a"
		a.delete()
		a.dir.listFiles().toList().findAll{ v -> v.name.startsWith(a.nameWithoutExtension) && v.extension ==~ /r\d+/ }.each{ v ->
			log.finest "FROM custom amc - Delete archive volume $v"
			v.delete()
		}
	}
}

// clean empty folders, clutter files, etc after move
if (clean) {
	if (['DUPLICATE', 'COPY', 'HARDLINK'].any{ it.equalsIgnoreCase(_args.action) } && temporaryFiles.size() > 0) {
		log.fine 'FROM custom amc - Clean temporary extracted files'
		// delete extracted files
		temporaryFiles.findAll{ it.isFile() }.sort().each{
			log.finest "FROM custom amc - Delete $it"
			it.delete()
		}
		// delete remaining empty folders
		temporaryFiles.findAll{ it.isDirectory() }.sort().reverse().each{
			log.finest "FROM custom amc - Delete $it"
			if (it.getFiles().size() == 0) {
				it.deleteDir()
			}
		}
	}

	// deleting remaining files only makes sense after moving files
	if ('MOVE'.equalsIgnoreCase(_args.action)) {
		def cleanerInput = args.size() > 0 ? args : ut.kind == 'multi' && ut.dir ? [ut.dir as File] : []
		cleanerInput = cleanerInput.findAll{ f -> f.exists() }
		if (cleanerInput.size() > 0) {
			log.fine 'FROM custom amc - Clean clutter files and empty folders'
			executeScript('cleaner', args.size() == 0 ? [root:true, ignore: ignore] : [root:false, ignore: ignore], cleanerInput)
		}
	}
}


if (destinationFiles.size() == 0) {
	fail "FROM custom amc - Finished without processing any files"
}
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Unofficial custom AMC script (intermediate users)

Post by kim »

HTPC (part 2):

Code: Select all

/************
***HTPC***
********** */


/** * Kodi helper functions */
def scanVideoLibrary(host, port){
	def json = [jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1]
	postKodiRPC(host, port, json)
}


def showNotification(host, port, title, message, image){
	def json = [jsonrpc:'2.0', method:'GUI.ShowNotification', params: [title: title, message: message, image: image], id: 1]
	postKodiRPC(host, port, json)
}


def postKodiRPC(host, port, json){
	def url = "http://$host:$port/jsonrpc"
	def data = JsonOutput.toJson(json)

	log.finest "POST: $url $data"
	new URL(url).post(data.getBytes('UTF-8'), 'application/json', [:])
}

/** * Plex helpers */
def refreshPlexLibrary(server, port, token){
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = "$protocol://$server:$port/library/sections/all/refresh"
	if (token){
		url += "?X-Plex-Token=$token"
	}
	log.finest "GET: $url"
	new URL(url).get()
}

/** * Emby helpers */
def refreshEmbyLibrary(server, port, token){
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = "$protocol://$server:$port/Library/Refresh"
	if (token){
		url += "?api_key=$token"
	}
	log.finest "POST: $url"
	new URL(url).post([:], [:])
}

/** * Sonarr helpers */
def rescanSonarrSeries(server, port, apikey, seriesId){
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = new URL("$protocol://$server:$port")
	def requestHeader = ['X-Api-Key': apikey]

	def series = new JsonSlurper().parseText(new URL(url, '/api/series').get(requestHeader).text)
	def id = series.find{ it.tvdbId == seriesId }?.id

	def command = [name: 'rescanSeries', seriesId: id]
	new URL(url, '/api/command').post(JsonOutput.toJson(command).getBytes('UTF-8'), 'application/json', requestHeader)
}

/** * Sickbeard helpers */
def rescanSickbeardSeries(server, port, apikey, seriesId){
	// use HTTPS if hostname is specified, use HTTP if IP is specified
	def protocol = server ==~ /localhost|[0-9.:]+/ ? 'http' : 'https'
	def url = "$protocol://$server:$port/api/$apikey?cmd=show.refresh&tvdbid=$seriesId"
	log.finest "GET: $url"
	new URL(url).get()
}

/** * TheTVDB artwork/nfo helpers */
def fetchSeriesBanner(outputFile, seriesId, bannerType, bannerType2, season, override, locale){
	if (outputFile.exists() && !override){
		log.finest "FROM custom htpc fetchSeriesBanner - Banner already exists: [$outputFile] / [$bannerType:$bannerType2]"
		return outputFile
	}

	// select and fetch banner
	def artwork = TheTVDB.getArtwork(seriesId, bannerType, locale)
	def banner = [locale.language, null].findResult { lang -> artwork.find{ it.matches(bannerType2, season, lang) } }
	if (banner == null){
		log.finest "FROM custom htpc fetchSeriesBanner - Banner not found: [$outputFile] / [$bannerType:$bannerType2]"
		return null
	}
	log.finest "FROM custom htpc fetchSeriesBanner - Downloading $bannerType: $banner => [$outputFile]"
	return banner.url.saveAs(outputFile)
}

def fetchSeriesFanart(outputFile, seriesId, type, season, override, locale){
	if (outputFile.exists() && !override){
		log.finest "FROM custom htpc fetchSeriesFanart - Fanart already exists: [$outputFile]"
		return outputFile
	}

	def artwork = FanartTV.getArtwork(seriesId, "tv", locale)
	def fanart = [locale.language, null].findResult{ lang -> artwork.find{ it.matches(type, season, lang) } }
	if (fanart == null){
		log.finest "FROM custom htpc fetchSeriesFanart - Fanart not found: [$outputFile] / [$type]"
		return null
	}
	log.finest "FROM custom htpc fetchSeriesFanart - Downloading $type: $fanart => [$outputFile]"
	return fanart.url.saveAs(outputFile)
}


def fetchSeriesNfo(outputFile, i, override, locale, nfotype, tvshowFile, episodeThumbfile, episodeId, nfoOnly){

	// Get list of actors
	if (nfotype == 'tvshow'){log.fine "FROM custom htpc fetchSeriesNfo - Downloading list of Actors"}
	def ActorsJson = TheTVDB.requestJson("series/${i.id}/actors", locale, Cache.ONE_MONTH)

	def dateAndTime = new Date().format('yyyy-MM-dd HH:mm:ss')

	def xml = XML {
		if (nfotype == 'tvshow'){

			log.finest "FROM custom htpc fetchSeriesNfo tvshow - Generate Series NFO: [$i.name] / ID: [$i.id]"

			tvshow {
				id(i.id)
				title(i.name)
				sorttitle([i.name, i.startDate as String].findAll{ it?.length() > 0 }.findResults{ it.sortName('$2') }.join('::'))
				status(i.status)
				premiered(i.startDate)
				year(i.startDate?.year)
				if (i.airsDayOfWeek != null){ airsday(i.airsDayOfWeek) }
				if (i.airsTime != null){ airstime(i.airsTime) }
				rating(i.rating)
				votes(i.ratingCount)
				plot(i.overview)
				thumb(aspect:"banner", i.bannerUrl)
				runtime(i.runtime)
				i.genres.each{
					genre(it)
				}
				mpaa(i.certification)
				studio(i.network)
				episodeguide { url(cache:"${i.id}.xml", "https://www.thetvdb.com/api/1D62F2F90030C444/series/${i.id}/all/${locale.language}.zip") }
				ActorsJson.data.each{ a ->
					actor {
						name(a.name)
						role(a.role)
						order(a.sortOrder)
						if (a.image != ''){
							thumb(TheTVDB.resolveImage(a.image))
						}
					}
				}
				if (i.imdbId != null){imdb(id:i.imdbId, "https://www.imdb.com/title/${i.imdbId}")}
				tvdb(id:i.id, "https://www.thetvdb.com/?tab=series&id=${i.id}")
				dateadded(dateAndTime)
			}
		}
		if (nfotype == 'fileNamedotNFO'){
			def mi = tryLogCatch{ tvshowFile ? MediaInfo.snapshot(tvshowFile) : null }

			episodeId.each{ epId ->

				// Get English Episodes info
				def EpisodeJson = TheTVDB.requestJson("episodes/${epId}", locale, Cache.ONE_WEEK)
				def epI = EpisodeJson.data

				// Download Episode-Thumb.jpg
				def epPicFile = epI.filename
				def epPicUrl = TheTVDB.resolveImage(epPicFile)
				fetchEpisodeArtwork(episodeThumbfile, epPicUrl, epPicFile, 'episode-thumb', override, nfoOnly)

				log.fine "FROM custom htpc fetchSeriesNfo fileNamedotNFO - Generate Episode NFO [$locale]: [$i.name] / Title: [$epI.episodeName] / ID: [$epId]"

				episodedetails {
					uniqueid(epI.id)
					title(epI.episodeName)
					showtitle(i.name)
					premiered(i.startDate)
					aired(epI.firstAired)
					season(epI.airedSeason)
					episode(epI.airedEpisodeNumber)
					if (epI.dvdSeason != null && epI.dvdEpisodeNumber != null){
						seasondvd(epI.dvdSeason)
						episodedvd(epI.dvdEpisodeNumber)
					}
					rating(epI.siteRating)
					votes(epI.siteRatingCount)
					if (epI.overview != null){ plot((epI.overview).trim()) }
					if (epPicFile != ''){ thumb(epPicUrl) }
					runtime(i.runtime)
					i.genres.each{
						genre(it)
					}
					mpaa(i.certification)
					studio(i.network)
					epI.directors.each{
							director(it)
					}
					epI.writers.each{
						credits(it)
					}
					epI.guestStars.each{ g ->
						actor {
							name(g)
							role("Guest star")
						}
					}
					ActorsJson.data.each{ a ->
						actor {
							name(a.name)
							role(a.role)
							order(a.sortOrder)
							if (a.image != ''){ thumb(TheTVDB.resolveImage(a.image)) }
						}
					}
					fileinfo {
						streamdetails {
							mi?.each { kind, streams ->
								def section = kind.toString().toLowerCase()
								streams.each { s ->
									if (section == 'general'){
										general {
											if (s.'FileName' != null){ filename((s.'FileName').trim()) }
											if (s.'FileExtension' != null){ fileextension((s.'FileExtension').trim()) }
											if (s.'FileSize' != null){ filesize((s.'FileSize').trim()) }
											if (s.'FileSize' != null){ FileSizeinMB(((s.'FileSize').toBigDecimal()/1024/1024).round(2)) }
											if (s.'Title' != null){ title((s.'Title').trim()) }
											if (s.'Encoded_Date' != null){ encoded((s.'Encoded_Date').trim()) }
											if (s.'Duration/String3' != null){ durationinhmsms((s.'Duration/String3').trim()) }
											if (s.'OverallBitRate' != null){ overallbitrate( Math.round((s.'OverallBitRate' as BigDecimal)/1000) ) }
											if (s.'Cover' != null){
												Cover(s.'Cover')
												if (s.'Attachments' != null){ Attachments(s.'Attachments') }
											}
										}
									}

									if (section == 'video'){
										video {
											codec((s.'CodecID/Hint' ?: s.'CodecID' ? (s.'CodecID'.contains('avc1') || s.'CodecID'.contains('xvid')) ? s.'CodecID'.replaceAll(/xvid/,'XviD') : s.'InternetMediaType'.contains('video/H264') ? 'h264' : s.'Encoded_Library_Name' ?: s.'Format' : s.'Codec').replaceAll(/\s\w{3,}/).space('').trim())
											if (s.'Duration' != null){
												BigDecimal durationINms = new BigDecimal(s.'Duration')
												durationinseconds(Math.round((durationINms)/1000))
												durationinminutes(Math.round((durationINms)/60000))
											}
											if (s.'DisplayAspectRatio' != null){ aspect((s.'DisplayAspectRatio').trim()) }
											if (s.'Width' != null){ width((s.'Width').trim()) }
											if (s.'Height' != null){ height((s.'Height').trim()) }
											if (s.'ScanType' != null){ scantype((s.'ScanType').trim()) }
											if (s.'BitRate' != null){ bitrate( Math.round((s.'BitRate' as BigDecimal)/1000) ) }
											if (s.'FrameRate' != null){ framerate((s.'FrameRate').trim()) }
											if (s.'StreamSize' != null){ streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2)) }
										}
									}

									if (section == 'audio'){
										audio {
											codec((s.'CodecID/Hint' ?: s.'Codec'.contains('DTS-HD') ? s.'Codec' : s.'Format').replaceAll(/\p{Punct}/, '').trim())
											if (s.'Format_Profile' != null){ codecformatprofile((s.'Format_Profile').replaceAll(/\s\/\s/, '|').replaceAll(/\|Core|\|LC|\|TrueHD/, '').trim().replaceAll(/\s/, '_')) }
											if (s.'Language/String3' != null){ language((s.'Language/String3').trim()) }
											else{language('und')}
											if (s.'Language/String' != null){ longlanguage((s.'Language/String').trim()) }
											else{longlanguage('undefined')}
											if (s.'Channel(s)' != null){
												if (s.'Channel(s)_Original' != null){ channels((s.'Channel(s)_Original').trim()) }
												else{channels((s.'Channel(s)').replaceAll(/Object\sBased\s\//, '')*.split(' / ')*.max().max())}
											}
											if (s.'BitRate' != null){ bitrate( (s.'BitRate').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
											if (s.'BitRate' == null && s.'BitRate_Maximum' != null){ bitrate( (s.'BitRate_Maximum').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
											if (s.'SamplingRate' != null){ samplingrateinkhz(s.'SamplingRate'.replaceAll(/^\s\/\s/, '').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
											if (s.'StreamSize' != null){ streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2)) }
										}
									}

									if (section == 'text'){
										subtitle {
											if (s.'Language/String3' != null){ language(s.'Language/String3') }
											if (s.'Language/String' != null){ longlanguage((s.'Language/String').replaceAll(/\s/, '').trim()) }
											if (s.'Title' != null){ title((s.'Title').trim()) }
											if (s.'Format' != null){ format((s.'Format').trim()) }
											if (s.'Forced' != null){ forced((s.'Forced').trim()) }
											if (s.'StreamSize' != null && s.'StreamSize' != '0'){ streamSizeinKB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1024).round(2)) }
										}
									}
								}
							}
						}
					}
					if (epI.imdbId != ''){imdb(id:epI.imdbId, "https://www.imdb.com/title/${epI.imdbId}")}
					tvdb(id:epI.id, "https://www.thetvdb.com/?tab=episode&id=${epI.id}")
					dateadded(dateAndTime)
				}
			}
		}
	}
	xml.saveAs(outputFile)
	log.finest "FROM custom htpc fetchSeriesNfo - Saving NFO: [$outputFile]"
}

/** * TMDB TV artwork/nfo and get Episode-Thumb from TheTVDB */
def fetchEpisodeArtwork(outputFile, picUrl, pic, category, override, nfoOnly){
	// fetch Only nfo
	if (nfoOnly){
		log.warning "FROM custom htpc fetchEpisodeArtwork - Artwork bypass ['nfoOnly' is 'y']"
		return null
	}
	if (outputFile.exists() && !override){
		log.finest "FROM custom htpc fetchEpisodeArtwork - $category already exists: [$outputFile]"
		return outputFile
	}
	if (pic == null){
		log.finest "FROM custom htpc fetchEpisodeArtwork - Artwork not found: [$outputFile] / [$category]"
		return null
	}
	log.finest "FROM custom htpc fetchEpisodeArtwork - Downloading $category: [$picUrl] => [$outputFile]"
	return picUrl.saveAs(outputFile)
}


def fetchSeriesNfoTMDB(outputFile, i, override, locale, nfotype, tvshowFile, episodeThumbfile, seasonNumber, episodeNumber, seriesDir, seasonDir, nfoOnly){

	// Define TMDB URL
	def imgBasePre = "https://image.tmdb.org/t/p/w300"

	// Get [filtered] Certifications
	def mpaaCountryLIST = ['CA', 'DE', 'GB', 'US']
	def mpaaRatings = i.content_ratings.results.findAll{r -> mpaaCountryLIST.any{r.iso_3166_1.contains(it)}}.sort().collect{"$it.rating ($it.iso_3166_1)"}.join(' / ')

	def dateAndTime = new Date().format('yyyy-MM-dd HH:mm:ss')

	def xml = XML {
		if (nfotype == 'tvshow'){
			def extIds = i.external_ids
			def posterPath = i.poster_path
			def backdropPath = i.backdrop_path
			def seriesPoster = TheMovieDB.resolveImage(posterPath)
			def seriesBackdrop = TheMovieDB.resolveImage(backdropPath)

			// fetch Episode Artwork
			fetchEpisodeArtwork(seriesDir.resolve('folder.jpg'), seriesPoster, posterPath, 'seriesPoster', override, nfoOnly)
			fetchEpisodeArtwork(seriesDir.resolve('fanart.jpg'), seriesBackdrop, backdropPath, 'seriesBackdrop', override, nfoOnly)
			i.seasons.each{ p ->
				if (p.season_number == seasonNumber){ fetchEpisodeArtwork(seasonDir.resolve('folder.jpg'), TheMovieDB.resolveImage(p.poster_path), p.poster_path, 'seasonPoster', override, nfoOnly) }
			}

			log.finest "FROM custom htpc fetchSeriesNfoTMDB tvshow - Generate Series NFO: [$i.name] / ID: [$i.id]"

			tvshow {
				id(i.id)
				title(i.name)
				originaltitle(i.original_name)
				sorttitle([i.name, i.first_air_date as String].findAll{ it?.length() > 0 }.findResults{ it.sortName('$2') }.join('::'))
				status(i.status)
				premiered(i.first_air_date)
				year(new Date().parse('yyyy-MM-dd', i.first_air_date)[Calendar.YEAR])
				season(i.number_of_seasons)
				episode(i.number_of_episodes)
				rating(i.vote_average)
				votes(i.vote_count)
				plot(i.overview)
				if (posterPath != null){ thumb(aspect:"poster", type:"season", season:"-1", seriesPoster) }
				if (backdropPath != null){ thumb(aspect:"fanart", seriesBackdrop) }
				runtime((i.episode_run_time).join('/'))
				i.genres.each{
					it.name.split(' & ').each{
						genre(it.replaceAll(/Sci-Fi/,'Science Fiction'))}
				}
				if (mpaaRatings != ''){ mpaa(mpaaRatings) }
				i.networks.each{
					studio(it.name)
				}
				i.credits.cast.each{ a ->
					actor {
						name(a.name)
						role(a.character)
						order(a.order)
						if (a.profile_path != null){ thumb(preview:"$imgBasePre${a.profile_path}", TheMovieDB.resolveImage(a.profile_path)) }
					}
				}
				i.origin_country.each{
					country(it)
				}
				languages(i.original_language)
				if (extIds.imdb_id != ''){ imdb(id:extIds.imdb_id, "https://www.imdb.com/title/${extIds.imdb_id}") }
				if (extIds.tvdb_id != ''){ tvdb(id:extIds.tvdb_id, "https://www.thetvdb.com/?tab=series&id=${extIds.tvdb_id}") }
				tmdb(id:i.id, "https://www.themoviedb.org/tv/${i.id}")
				dateadded(dateAndTime)
			}
		}

		if (nfotype == 'fileNamedotNFO'){
			def mi = tryLogCatch{ tvshowFile ? MediaInfo.snapshot(tvshowFile) : null }


			episodeNumber.each{ epNumber ->

				// Get default language Episodes info
				def eAddOns = ["append_to_response":"external_ids"]
				def epI = TheMovieDB.request("tv/${i.id}/season/${seasonNumber}/episode/${epNumber}", eAddOns, locale)
				def extEids = epI.external_ids

				// Download Episode-Thumb.jpg
				def epPicFile = epI.still_path
				def epPicUrl = TheMovieDB.resolveImage(epPicFile)
				fetchEpisodeArtwork(episodeThumbfile, epPicUrl, epPicFile, 'episode-thumb', override, nfoOnly)

				log.fine "FROM custom htpc fetchSeriesNfoTMDB fileNamedotNFO - Generate Episode NFO: [$i.name] / Title: [$epI.name] / ID: [$epI.id]"

				episodedetails {
					uniqueid(epI.id)
					title(epI.name)
					showtitle(i.name)
					originaltitle(i.original_name)
					premiered(i.first_air_date)
					aired(epI.air_date)
					season(epI.season_number)
					episode(epI.episode_number)
					rating(epI.vote_average)
					votes(epI.vote_count)
					if (epI.overview != ''){ plot((epI.overview).trim()) }
					if (epPicFile != null){ thumb(preview:"$imgBasePre${epPicFile}", epPicUrl) }
					runtime((i.episode_run_time).join('/'))
					i.genres.each{
						it.name.split(' & ').each{
							genre(it.replaceAll(/Sci-Fi/,'Science Fiction'))}
					}
					if (mpaaRatings != ''){ mpaa(mpaaRatings) }
					i.networks.each{
						studio(it.name)
					}
					epI.guest_stars.each{ g ->
						actor {
							name(g.name)
							role(("${g.character} (guest star)").trim())
							if (g.profile_path != null){ thumb(preview:"$imgBasePre${g.profile_path}", TheMovieDB.resolveImage(g.profile_path)) }
						}
					}
					i.credits.cast.each{ a ->
						actor {
							name(a.name)
							role(a.character)
							order(a.order)
							if (a.profile_path != null){ thumb(preview:"$imgBasePre${a.profile_path}", TheMovieDB.resolveImage(a.profile_path)) }
						}
					}
					epI.crew.each{ p ->
						if (p.job == 'Director'){ director(p.name) }
						else if (p.department == 'Writing'){ credits("$p.name ($p.job)") }
					}
					fileinfo {
						streamdetails {
							mi?.each { kind, streams ->
								def section = kind.toString().toLowerCase()
								streams.each { s ->
									if (section == 'general'){
										general {
											if (s.'FileName' != null){ filename((s.'FileName').trim()) }
											if (s.'FileExtension' != null){ fileextension((s.'FileExtension').trim()) }
											if (s.'FileSize' != null){ filesize((s.'FileSize').trim()) }
											if (s.'FileSize' != null){ FileSizeinMB(((s.'FileSize').toBigDecimal()/1024/1024).round(2)) }
											if (s.'Title' != null){ title((s.'Title').trim()) }
											if (s.'Encoded_Date' != null){ encoded((s.'Encoded_Date').trim()) }
											if (s.'Duration/String3' != null){ durationinhmsms((s.'Duration/String3').trim()) }
											if (s.'OverallBitRate' != null){ overallbitrate( Math.round((s.'OverallBitRate' as BigDecimal)/1000) ) }
											if (s.'Cover' != null){
												Cover(s.'Cover')
												if (s.'Attachments' != null){ Attachments(s.'Attachments') }
											}
										}
									}

									if (section == 'video'){
										video {
											codec((s.'CodecID/Hint' ?: s.'CodecID' ? (s.'CodecID'.contains('avc1') || s.'CodecID'.contains('xvid')) ? s.'CodecID'.replaceAll(/xvid/,'XviD') : s.'InternetMediaType'.contains('video/H264') ? 'h264' : s.'Encoded_Library_Name' ?: s.'Format' : s.'Codec').replaceAll(/\s\w{3,}/).space('').trim())
											if (s.'Duration' != null){
												BigDecimal durationINms = new BigDecimal(s.'Duration')
												durationinseconds(Math.round((durationINms)/1000))
												durationinminutes(Math.round((durationINms)/60000))
											}
											if (s.'DisplayAspectRatio' != null){ aspect((s.'DisplayAspectRatio').trim()) }
											if (s.'Width' != null){ width((s.'Width').trim()) }
											if (s.'Height' != null){ height((s.'Height').trim()) }
											if (s.'ScanType' != null){ scantype((s.'ScanType').trim()) }
											if (s.'BitRate' != null){ bitrate( Math.round((s.'BitRate' as BigDecimal)/1000) ) }
											if (s.'FrameRate' != null){ framerate((s.'FrameRate').trim()) }
											if (s.'StreamSize' != null){ streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2)) }
										}
									}

									if (section == 'audio'){
										audio {
											codec((s.'CodecID/Hint' ?: s.'Codec'.contains('DTS-HD') ? s.'Codec' : s.'Format').replaceAll(/\p{Punct}/, '').trim())
											if (s.'Format_Profile' != null){ codecformatprofile((s.'Format_Profile').replaceAll(/\s\/\s/, '|').replaceAll(/\|Core|\|LC|\|TrueHD/, '').trim().replaceAll(/\s/, '_')) }
											if (s.'Language/String3' != null){ language((s.'Language/String3').trim()) }
											else{language('und')}
											if (s.'Language/String' != null){ longlanguage((s.'Language/String').trim()) }
											else{longlanguage('undefined')}
											if (s.'Channel(s)' != null){
												if (s.'Channel(s)_Original' != null){ channels((s.'Channel(s)_Original').trim()) }
												else{channels((s.'Channel(s)').replaceAll(/Object\sBased\s\//, '')*.split(' / ')*.max().max())}
											}
											if (s.'BitRate' != null){ bitrate( (s.'BitRate').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
											if (s.'BitRate' == null && s.'BitRate_Maximum' != null){ bitrate( (s.'BitRate_Maximum').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
											if (s.'SamplingRate' != null){ samplingrateinkhz(s.'SamplingRate'.replaceAll(/^\s\/\s/, '').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
											if (s.'StreamSize' != null){ streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2)) }
										}
									}

									if (section == 'text'){
										subtitle {
											if (s.'Language/String3' != null){ language(s.'Language/String3') }
											if (s.'Language/String' != null){ longlanguage((s.'Language/String').replaceAll(/\s/, '').trim()) }
											if (s.'Title' != null){ title((s.'Title').trim()) }
											if (s.'Format' != null){ format((s.'Format').trim()) }
											if (s.'Forced' != null){ forced((s.'Forced').trim()) }
											if (s.'StreamSize' != null && s.'StreamSize' != '0'){ streamSizeinKB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1024).round(2)) }
										}
									}
								}
							}
						}
					}
				if (extEids.imdb_id != null){ imdb(id:extEids.imdb_id, "https://www.imdb.com/title/${extEids.imdb_id}") }
				if (extEids.tvdb_id != null){ tvdb(id:extEids.tvdb_id, "https://www.thetvdb.com/?tab=episode&id=${extEids.tvdb_id}") }
				tmdb(id:epI.id, "https://www.themoviedb.org/tv/${i.id}/season/${seasonNumber}/episode/${epNumber}")
				dateadded(dateAndTime)
				}
			}
		}
	}
	xml.saveAs(outputFile)
	log.finest "FROM custom htpc fetchSeriesNfoTMDB - Saving NFO: [$outputFile]"
}


def fetchSeriesArtworkAndNfo(seriesDir, seasonDir, seriesId, season, override, locale, tvshowFile = null, episodeNumber, sxe, EpisodeOrder = _args.order, episodeId, extras = false, nfoOnly, tvDB){
	log.fine "FROM custom htpc fetchSeriesArtworkAndNfo - Season: [$season] / SeasonDir: [$seasonDir] / SeriesId: [$seriesId]"

	if (episodeNumber instanceof ArrayList){ log.fine "FROM custom htpc fetchSeriesArtworkAndNfo - Episodes: $episodeNumber / SxE: $sxe / EpisodeId: $episodeId" }
	else{ log.fine "FROM custom htpc fetchSeriesArtworkAndNfo - Episode: [$episodeNumber] / SxE: [$sxe] / EpisodeId: [$episodeId]" }

	tryLogCatch {

		// Define nfo
		def seriesInfo
		def sAddOns = ["append_to_response":"external_ids,credits,content_ratings"]

		// def String fname = tvshowFile.name
		def String fileNamedotNFO = "${tvshowFile.name.nameWithoutExtension}.nfo"

		// Define episodeThumbfile
		def episodePicExt = '-thumb.jpg'
		def String fileNameThumb = "${tvshowFile.nameWithoutExtension}$episodePicExt"
		def episodeThumbfile = seasonDir.resolve(fileNameThumb)

		if (tvDB == 'TheMovieDB::TV'){
			seriesInfo = TheMovieDB.request("tv/${seriesId}", sAddOns, locale)
			fetchSeriesNfoTMDB(seriesDir.resolve('tvshow.nfo'), seriesInfo, override, locale, 'tvshow', tvshowFile, null, season, episodeNumber, seriesDir, seasonDir, nfoOnly)
			fetchSeriesNfoTMDB(seasonDir.resolve(fileNamedotNFO), seriesInfo, override, locale, 'fileNamedotNFO', tvshowFile, episodeThumbfile, season, episodeNumber, seriesDir, seasonDir, nfoOnly)
		}

		else{
			seriesInfo = TheTVDB.getSeriesInfo(seriesId, locale)
			fetchSeriesNfo(seriesDir.resolve('tvshow.nfo'), seriesInfo, override, locale, 'tvshow', tvshowFile, null, episodeId, nfoOnly)
			fetchSeriesNfo(seasonDir.resolve(fileNamedotNFO), seriesInfo, override, locale, 'fileNamedotNFO', tvshowFile, episodeThumbfile, episodeId, nfoOnly)

			// fetch Only nfo, if not set
			if (!nfoOnly){
				// fetch season banners
				if (seasonDir != seriesDir){
					fetchSeriesBanner(seasonDir.resolve('folder.jpg'), seriesId, 'season', 'season', season, override, locale)
					fetchSeriesBanner(seasonDir.resolve('banner.jpg'), seriesId, 'seasonwide', 'seasonwide', season, override, locale)
				}

				// fetch series banner, fanart, posters, etc
				['680x1000', null].findResult{ fetchSeriesBanner(seriesDir.resolve('folder.jpg'), seriesId, 'poster', it, null, override, locale) }
				['graphical', null].findResult{ fetchSeriesBanner(seriesDir.resolve('banner.jpg'), seriesId, 'series', it, null, override, locale) }

				// fetch highest resolution fanart
				['1920x1080', '1280x720', null].findResult{ fetchSeriesBanner(seriesDir.resolve('fanart.jpg'), seriesId, 'fanart', it, null, override, locale) }

			}
		}

		// fetch Only nfo, if not set
		if (!nfoOnly){
			 // e.g season13-poster.jpg
			def String seasonPadnumber = season.pad(2)
			def seasonposter = "season$seasonPadnumber-poster.jpg"

			// folder image (resuse series poster if possible)
			copyIfPossible(seasonDir.resolve('folder.jpg'), seriesDir.resolve(seasonposter))

			// folder image (resuse series poster if possible)
			copyIfPossible(seriesDir.resolve('fanart.jpg'), seriesDir.resolve('season-all-fanart.jpg'))
			copyIfPossible(seriesDir.resolve('folder.jpg'), seriesDir.resolve('season-all-poster.jpg'))

			// download extras files
			if (extras){
				// fetch fanart
				['hdclearart', 'clearart'].findResult{ type -> fetchSeriesFanart(seriesDir.resolve('clearart.png'), seriesId, type, null, override, locale) }
				['hdtvlogo', 'clearlogo'].findResult{ type -> fetchSeriesFanart(seriesDir.resolve('logo.png'), seriesId, type, null, override, locale) }
				fetchSeriesFanart(seriesDir.resolve('landscape.jpg'), seriesId, 'tvthumb', null, override, locale)

				// fetch season fanart
				if (seasonDir != seriesDir){
					fetchSeriesFanart(seasonDir.resolve('landscape.jpg'), seriesId, 'seasonthumb', season, override, locale)
				}
			}
		}
	}
}

/** * TheMovieDB artwork/nfo helpers */
def fetchMovieArtwork(outputFile, movieInfo, category, override, locale){

	// select and fetch artwork
	if (outputFile.exists() && !override){
		log.finest "FROM custom htpc fetchMovieArtwork - Artwork already exists: [$outputFile]"
		return outputFile
	}

	def artwork = TheMovieDB.getArtwork(movieInfo.id, category, locale)

	if (category == 'posters'){
		def selection = [locale.language, 'en', 'xx', null].findResult{ lang -> artwork.find{ it.matches(lang) } }
		if (selection == null){
			log.finest "FROM custom htpc fetchMovieArtwork posters - Artwork not found: [$outputFile]"
			return null
		}
		log.finest "FROM custom htpc fetchMovieArtwork posters - Downloading poster $selection => [$outputFile]"
		return selection.url.saveAs(outputFile)
	}
	if (category == 'backdrops'){
		def selection = [null, 'null', locale.language, 'en', 'xx'].findResult{ lang -> artwork.find{ it.matches(lang) } }
		if (selection == null){
			log.finest "FROM custom htpc fetchMovieArtwork backdrops - Artwork not found: [$outputFile]"
			return null
		}
		log.finest "FROM custom htpc fetchMovieArtwork backdrops - Downloading fanart $selection => [$outputFile]"
		return selection.url.saveAs(outputFile)
	}
}


def fetchAllMovieArtwork(outputFolder, prefix, movieInfo, category, override, locale){	/*** Only if extras is y (true) ***/
	// select and fetch artwork
	def artwork = TheMovieDB.getArtwork(movieInfo.id, category, locale)
	def selection = [locale.language, 'en', null].findResults{ lang -> artwork.findAll{ it.matches(lang) } }.flatten().unique()
	if (selection == null){
		log.finest "FROM custom htpc fetchAllMovieArtwork - Artwork not found: [$outputFolder]"
		return null
	}
	selection.eachWithIndex{ s, i ->
		def outputFile = new File(outputFolder, "${prefix}${i+1}.jpg")
		if (outputFile.exists() && !override){ log.finest "FROM custom htpc fetchAllMovieArtwork - Artwork already exists: [$outputFile]" }
		else{
			log.finest "FROM custom htpc fetchAllMovieArtwork - Downloading: $s => [$outputFile]"
			s.url.saveAs(outputFile)
		}
	}
}


def fetchMovieFanart(outputFile, movieInfo, type, override, locale){	/*** Only if extras is y (true) ***/
	if (outputFile.exists() && !override){
		log.finest "FROM custom htpc fetchMovieFanart - Fanart already exists: [$outputFile]"
		return outputFile
	}

	def artwork = FanartTV.getArtwork(movieInfo.id, "movies", locale)
	def fanart = [locale, null].findResult{ lang -> artwork.find{ it.matches(type, lang) } }
	if (fanart == null){
		log.finest "FROM custom htpc fetchMovieFanart - Fanart not found: File: [$outputFile.name] / Type: [$type]"
		return null
	}
	log.finest "FROM custom htpc fetchMovieFanart - Downloading: $fanart => [$outputFile]"
	return fanart.url.saveAs(outputFile)
}


def fetchMovieNfo(outputFile, i, movieFile, fileset){
	log.finest "FROM custom htpc fetchMovieNfo - Generate Movie NFO: [$i.name] / ID [$i.id]"

	// Used for checksum and avg. of e.g. bitrate
	def filesetSIZE = fileset.size()
	def filesetCounter = 0

	// MediaInfo - Generate from all files in fileset
	def mis = tryLogCatch{fileset ? fileset.findResults{MediaInfo.snapshot(it)} : null }
	def filesizeinBYTES, overallBITRATE, durationinMS, videoBITRATE

	if (mis != null && filesetSIZE >= 2){
		log.finest "FROM custom htpc fetchMovieNfo - Multi-part movie, getting data for 'fileinfo-summary'"
		// MediaInfo - Get General values for fileinfo-summary
		filesizeinBYTES = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[0]['FileSize'].join('')) } ?: null}
			catch (Exception e){log.warning "FROM custom htpc fetchMovieNfo - Warning: General FileSize missing, used for filesize and filesizetotal under fileinfo-summary"}
		}
		overallBITRATE = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[0]['OverallBitRate'].join('')) } ?: null}
			catch (Exception e){log.warning "FROM custom htpc fetchMovieNfo - Warning: General OverallBitRate missing, used for overallbitrate and overallbitrateavg under fileinfo-summary"}
		}
		durationinMS = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[0]['Duration'].join('')) } ?: null}
			catch (Exception e){log.warning "FROM custom htpc fetchMovieNfo - Warning: General Duration missing, used for durationinhmsms and durationinminutestotal under fileinfo-summary"}
		}

		// MediaInfo - Get Video values for fileinfo-summary
		videoBITRATE = tryLogCatch{
			try{mis.findResults{new BigDecimal(it.values()[1]['BitRate'].join('')) } ?: null}
			catch (NumberFormatException | Exception e){log.warning "FROM custom htpc fetchMovieNfo - Warning: Video BitRate missing, used for bitrate and bitrateavg under fileinfo-summary"}
		}
	}

	// Get [filtered] Alternative Titles, only if any found
	def CustomAltLIST = ''
	if (i.alternativeTitles[0] != null){
		def altCountryLIST = ['AU', 'CA', 'DK', 'GB', 'NO', 'SE', 'US']
		log.fine "FROM custom htpc fetchMovieNfo - Downloading Alternative Titles for these languages: $altCountryLIST"
		CustomAltLIST = TheMovieDB.getAlternativeTitles(i.id).findAll{key, value -> altCountryLIST.any{key.contains(it)}}.sort().findResults{key, value -> value.findResults{"$it ($key)"} }.flatten()
	}

	// Get [filtered] Certifications
	def certCountryLIST = ['DK', 'NO', 'SE', 'US']
	def CustomCertLIST = i.certifications.findAll{key, value -> certCountryLIST.any{key.contains(it)}}.sort().findResults{key, value -> value + ' ('+key+')'}

	def KODI_Trailer = "plugin://plugin.video.youtube/?action=play_video&videoid="
	def Youtube_Video = "https://www.youtube.com/watch?v="
	def dateAndTime = new Date().format('yyyy-MM-dd HH:mm:ss')

	def xml = XML {
		movie {
			id('tt' + (i.imdbId ?: 0).pad(7))
			title(i.name)
			originaltitle(i.originalName)
			CustomAltLIST.each{
				alternativetitles(it)
			}
			sorttitle([i.collection, i.name, i.released as String].findAll{ it?.length() > 0 }.findResults{ it.sortName('$2') }.join('::'))
			set(i.collection)
			i.productionCompanies.each{
				studio(it)
			}
			if (i.adult){ genre('Adult') }
			i.genres.each{
				genre(it)
			}
			year(i.released?.year)
			premiered(i.released)
			runtime(i.runtime)
			rating(i.rating)
			votes(i.votes)
			tagline(i.tagline)
			plot(i.overview)
			mpaa((CustomCertLIST).join(' / '))
			i.people.each{ p ->
				if (p.director){
					director(p.name)
				} else if (p.job ==~ /Producer/){
					producer(p.name)
				} else if (p.actor){
					actor {
						name((p.name).trim())
						role((p.character).replaceAll(/\s+/, ' ').trim())
						order(p.order)
						if (p.image != null){
							thumb(p.image)
						}
					}
				} else if (!p.actor && !p.department){
					actor {
						name((p.name).trim())
						if (p.character != null){
							role((p.character).replaceAll(/\s+/, ' ').trim())
						}
						order(p.order)
						if (p.image != null){
							thumb(p.image)
						}
					}
 				} else if (p.department == 'Writing'){
					credits("$p.name ($p.job)")
				}
			}
			i.productionCountries.each{
				country(it)
			}
			i.spokenLanguages.each{
				languages(it)
			}
			i.trailers.each{
				if (it.type == 'Trailer'){ trailer(name:it.name, KODI_Trailer+it.key) }
			}
			i.trailers.each{
				if (it.type == 'Trailer'){ youtube(Youtube_Video+it.key) }
			}

			mis.each { f ->
				fileinfo {
					streamdetails {
						f.each { kind, streams ->
							def section = kind.toString().toLowerCase()
							streams.each { s ->
								if (section == 'general'){
									general {
										if (s.'FileName' != null){ filename((s.'FileName').trim()) }
										if (s.'FileExtension' != null){ fileextension((s.'FileExtension').trim()) }
										if (s.'FileSize' != null){ filesize((s.'FileSize').trim()) }
										if (s.'FileSize' != null){ FileSizeinMB(((s.'FileSize').toBigDecimal()/1024/1024).round(2)) }
										if (s.'Title' != null){ title((s.'Title').trim()) }
										if (s.'Encoded_Date' != null){ encoded((s.'Encoded_Date').trim()) }
										if (s.'Duration/String3' != null){ durationinhmsms((s.'Duration/String3').trim()) }
										if (s.'OverallBitRate' != null){ overallbitrate( Math.round((s.'OverallBitRate' as BigDecimal)/1000) ) }
										if (s.'Cover' != null){
											Cover(s.'Cover')
											if (s.'Attachments' != null){ Attachments(s.'Attachments') }
										}
										if (filesetCounter < filesetSIZE){
											// Get filehash
											checksum(computeHash(fileset[filesetCounter]))
											filesetCounter++
										}
									}
								}

								if (section == 'video'){
									video {
										codec((s.'CodecID/Hint' ?: s.'CodecID' ? (s.'CodecID'.contains('avc1') || s.'CodecID'.contains('xvid')) ? s.'CodecID'.replaceAll(/xvid/,'XviD') : s.'InternetMediaType'.contains('video/H264') ? 'h264' : s.'Encoded_Library_Name' ?: s.'Format' : s.'Codec').replaceAll(/\s\w{3,}/).space('').trim())
										if (s.'Duration' != null){
											BigDecimal durationINms = new BigDecimal(s.'Duration')
											durationinseconds(Math.round((durationINms)/1000))
											durationinminutes(Math.round((durationINms)/60000))
										}
										if (s.'DisplayAspectRatio' != null){ aspect((s.'DisplayAspectRatio').trim()) }
										if (s.'Width' != null){ width((s.'Width').trim()) }
										if (s.'Height' != null){ height((s.'Height').trim()) }
										if (s.'ScanType' != null){ scantype((s.'ScanType').trim()) }
										if (s.'BitRate' != null){ bitrate(Math.round((s.'BitRate' as BigDecimal)/1000)) }
										if (s.'FrameRate' != null){ framerate((s.'FrameRate').trim()) }
										if (s.'StreamSize' != null){ streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2)) }
									}
								}

								if (section == 'audio'){
									audio {
										codec((s.'CodecID/Hint' ?: s.'Codec'.contains('DTS-HD') ? s.'Codec' : s.'Format').replaceAll(/\p{Punct}/, '').trim())
										if (s.'Format_Profile' != null){ codecformatprofile((s.'Format_Profile').replaceAll(/\s\/\s/, '|').replaceAll(/\|Core|\|LC|\|TrueHD/, '').trim().replaceAll(/\s/, '_')) }
										if (s.'Language/String3' != null){ language((s.'Language/String3').trim()) }
										else{language('und')}
										if (s.'Language/String' != null){ longlanguage((s.'Language/String').trim()) }
										else{longlanguage('undefined')}
										if (s.'Channel(s)' != null){
											if (s.'Channel(s)_Original' != null){ channels((s.'Channel(s)_Original').trim()) }
											else{channels((s.'Channel(s)').replaceAll(/Object\sBased\s\//, '')*.split(' / ')*.max().max())}
										}
										if (s.'BitRate' != null){ bitrate( (s.'BitRate').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
										if (s.'BitRate' == null && s.'BitRate_Maximum' != null){ bitrate( (s.'BitRate_Maximum').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
										if (s.'SamplingRate' != null){ samplingrateinkhz(s.'SamplingRate'.replaceAll(/^\s\/\s/, '').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1000) }
										if (s.'StreamSize' != null){ streamSizeinMB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toBigDecimal().max()/1024/1024).round(2)) }
									}
								}

								if (section == 'text'){
									subtitle {
										if (s.'Language/String3' != null){ language(s.'Language/String3') }
										if (s.'Language/String' != null){ longlanguage((s.'Language/String').replaceAll(/\s/, '').trim()) }
										if (s.'Title' != null){ title((s.'Title').trim()) }
										if (s.'Format' != null){ format((s.'Format').trim()) }
										if (s.'Forced' != null){ forced((s.'Forced').trim()) }
										if (s.'StreamSize' != null && s.'StreamSize' != '0'){ streamSizeinKB(((s.'StreamSize').replaceAll(/[a-zA-Z]/, '0').split('/')*.toInteger().max()/1024).round(2)) }
									}
								}
							}
						}
					}
				}
			}

			if (filesetSIZE >= 2){
				'fileinfo-summary'{
					general {
						if (filesizeinBYTES != null){ filesizetotal(filesizeinBYTES.sum()) }
						if (overallBITRATE != null){ overallbitrateavg(Math.round(overallBITRATE.sum()/1000/filesetSIZE)) }
					}
					video {
						if (durationinMS != null){ durationinminutestotal(Math.round(durationinMS.sum()/60000)) }
						if (videoBITRATE != null){ bitrateavg(Math.round(videoBITRATE.sum()/1000/filesetSIZE)) }
					}
				}
			}

			imdb(id:"tt" + (i.imdbId ?: 0).pad(7), "http://www.imdb.com/title/tt" + (i.imdbId ?: 0).pad(7))
			tmdb(id:i.id, "https://www.themoviedb.org/movie/${i.id}")
			dateadded(dateAndTime)
		}
	}
	xml.saveAs(outputFile)
	log.finest "FROM custom htpc fetchMovieNfo - Save Movie NFO to: [$outputFile]"
}


def fetchMovieArtworkAndNfo(movieDir, movie, movieFile = null, extras = false, override, locale, fileset, nfoOnly){

	tryLogCatch {
		// Removing -Trailer file from fileset/NFO
		fileset = fileset.findAll{ !it.getName().matches(/(?i:.+\-Trailer\.\w{3})/) }.findResults{ it }

		// Get Info
		def movieInfo = TheMovieDB.getMovieInfo(movie, locale, true)

		if (nfoOnly){
			// fetch Only nfo
			fetchMovieNfo(movieDir.resolve('movie.nfo'), movieInfo, movieFile, fileset)
		}
		else{
			// fetch nfo
			fetchMovieNfo(movieDir.resolve('movie.nfo'), movieInfo, movieFile, fileset)

			// fetch movie fanart and poster
			fetchMovieArtwork(movieDir.resolve('folder.jpg'), movieInfo, 'posters', override, locale)
			fetchMovieArtwork(movieDir.resolve('fanart.jpg'), movieInfo, 'backdrops', override, locale)

			// generate url files
			if (extras){
				[[db:'imdb', id:movieInfo.imdbId, url:'http://www.imdb.com/title/tt' + (movieInfo.imdbId ?: 0).pad(7)], [db:'tmdb', id:movieInfo.id, url:"https://www.themoviedb.org/movie/${movieInfo.id}"]].each{
					if (it.id > 0){
						def content = "[InternetShortcut]\nURL=${it.url}\n"
						content.saveAs(movieDir.resolve("${it.db}.url"))
					}
				}
			}

			// download extras files
			if (extras){
				['hdmovieclearart', 'movieart'].findResult { type -> fetchMovieFanart(movieDir.resolve('clearart.png'), movieInfo, type, override, locale) }
				['hdmovielogo', 'movielogo'].findResult { type -> fetchMovieFanart(movieDir.resolve('logo.png'), movieInfo, type, override, locale) }
				['bluray', 'dvd', null].findResult { diskType -> fetchMovieFanart(movieDir.resolve('disc.png'), movieInfo, diskType, override, locale) }

				fetchAllMovieArtwork(movieDir.resolve('extrafanart'), 'fanart', movieInfo, 'backdrops', override, locale)

				def SameNameAsMovie = movieFile.nameWithoutExtension+'.jpg'
				copyIfPossible(movieDir.resolve('folder.jpg'), movieDir.resolve(SameNameAsMovie))
				log.finest "Copy and Rename [$movieDir\\folder.jpg] => [$SameNameAsMovie]"
			}

			// folder image (reuse movie poster if possible)
			copyIfPossible(movieDir.resolve('poster.jpg'), movieDir.resolve('folder.jpg'))
		}
	}
}


def copyIfPossible(File src, File dst){
	if (src.exists() && !dst.exists()){
		src.copyAs(dst)
	}
}
cafevincent
Posts: 54
Joined: 22 Jun 2017, 10:08

Re: Unofficial custom AMC script (intermediate users)

Post by cafevincent »

kim wrote: 16 Nov 2017, 19:23 ***Version 2***

warning, this is a very modified version of amc+htpc (2in1)

This script now support:
TV episodes from both thetvdb and themoviedb (Kodi style + a lot more info)
Movies (Kodi style + a lot more info)

Use like official AMC, but you may need a local copy of this:
https://github.com/filebot/scripts/blob ... ner.groovy

To get TV from themoviedb:
--def tmdbTV=y (default is use thetvdb)

Other new/changed stuff:
--def nfoOnly=y (get/make ONLY Movie/TV NFO's)
--def extras=y

sorry but "The maximum number of allowed characters is 60000."
so I most split it up in 2 posts

copy and save this as SOMETHING.groovy e.g. "amc_custom.groovy"
I have not used scripts before. can I use this to only rename files using TheMovieDb? I can skip the sorting right?
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (intermediate users)

Post by kim »

what do you mean with "sorting" ?

It renames, yes... based on data from e.g. themoviedb and copy/moves the files.

If you mean you dont want the "Season" folder e.g. 12 Monkeys/Season 04
then edit your format aka 100% up to you

If you don't want the pics/fanart you can use
--def nfoOnly=y (get/make ONLY Movie/TV NFO's)
If you want ONLY rename +copy/move

Code: Select all

--def artwork=n
If new to AMC/scripts maybe look here:
viewtopic.php?f=8&t=6212#p35810
viewtopic.php?f=4&t=215
cafevincent
Posts: 54
Joined: 22 Jun 2017, 10:08

Re: Unofficial custom AMC script (intermediate users)

Post by cafevincent »

so is it possible to use this to only rename a single %path_to_filename% from command line, into this custom format?

Code: Select all

{n} {s00e00} {t} {genres} {certification} r{omdb.rating} {vf} {vc} {ac}-{omdb.imdbId}
I am trying to fulfill my command line, realizing I need seriesFormat.groovy & Movie counterpart with folder structures I don't want to use, and I don't get where to enter my custom formatting, or if it even does single files.
h00re1337
Posts: 1
Joined: 13 Jan 2019, 11:48

Re: Unofficial custom AMC script (intermediate users)

Post by h00re1337 »

hey kim. i use ur script and for season banners + nfo its working perfect

but i get banner not found for the series. u can help me?

Code: Select all

Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/poster.jpg / poster:680x1000
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/poster.jpg / poster:null
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/banner.jpg / series:graphical
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/banner.jpg / series:null
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/fanart.jpg / fanart:1920x1080
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/fanart.jpg / fanart:1280x720
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/fanart.jpg / fanart:null
Fetching /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/2. Staffel/poster.jpg => [season/2, de, 5.3, https://thetvdb.com/banners/seasons/253350-2-4.jpg]
Banner not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/2. Staffel/banner.jpg / seasonwide:seasonwide
Fanart already exists: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/clearart.png
Fanart already exists: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/logo.png
Fanart already exists: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/landscape.jpg
Fanart not found: /srv/dev-disk-by-id-md-name-OMV-HP/Serien-test/bla/Anger Management/2. Staffel/landscape.jpg / seasonthumb

i use --def artwork=y --def clean=y --def nfoOnly=n --def extras=n
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (intermediate users)

Post by kim »

Banner not found:
is = Filebot can not find the file (does not exist)
Looks like you use

Code: Select all

--lang de
try english

have you changed the code ?

Code: Select all

if (banner == null) {
		log.finest "FROM custom htpc fetchSeriesBanner - Banner not found: [$outputFile] / [$bannerType:$bannerType2]"
		return null
	}
because
in the log file "Banner not found:"
should be "FROM custom htpc fetchSeriesBanner - Banner not found:"

and --def extras=n should skip clearart, logo and landscape files (not download)

Code: Select all

			// download extras files
			if (extras){
				// fetch fanart
				['hdclearart', 'clearart'].findResult{ type -> fetchSeriesFanart(seriesDir.resolve('clearart.png'), seriesId, type, null, override, locale) }
				['hdtvlogo', 'clearlogo'].findResult{ type -> fetchSeriesFanart(seriesDir.resolve('logo.png'), seriesId, type, null, override, locale) }
				fetchSeriesFanart(seriesDir.resolve('landscape.jpg'), seriesId, 'tvthumb', null, override, locale)

				// fetch season fanart
				if (seasonDir != seriesDir){
					fetchSeriesFanart(seasonDir.resolve('landscape.jpg'), seriesId, 'seasonthumb', season, override, locale)
				}
			}
are you using e.g. this to launch the script ?

Code: Select all

filebot -script amc_custom.groovy... 
so...

Code: Select all

filebot -script amc_custom.groovy %TVFolder% --output %ScapedFolder% --log all --log-file "...data\logs\amc.log" --action move --conflict skip --def artwork=y --def clean=y --def extras=n --def ut_label=TV --def seriesFormat=@"seriesFormat.groovy" --def nfoOnly=n --def tmdbTV=n
and not the Online version

Code: Select all

filebot -script fn:amc
EDIT: I just tested with "--lang de --def artwork=y --def clean=y --def extras=n" and this gets this:
Anger Management\Season 02\season02-poster.jpg
Anger Management\Season 02\tvshow.nfo
Anger Management\Season 02\Anger.Management.S02E01.Charlies.liebste.Schwester.mkv
Anger Management\Season 02\Anger.Management.S02E01.Charlies.liebste.Schwester.nfo
Anger Management\Season 02\Anger.Management.S02E01.Charlies.liebste.Schwester-thumb.jpg
Anger Management\Season 02\folder.jpg
and
with the default ENGLISH I get same files +:
banner.jpg
fanart.jpg
season-all-fanart.jpg
season-all-poster.jpg
boundless
Posts: 1
Joined: 19 Nov 2019, 23:40

Re: Unofficial custom AMC script (intermediate users)

Post by boundless »

Thanks kim for this updated amc script! themoviedb is working great for TV.

I'm just getting a problem now for Pushbullet notifications, have you seen this before?

Code: Select all

FROM custom amc - Sending PushBullet report
MissingMethodException: No signature of method: org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.PushBullet() is applicable for argument types: (java.lang.String) values: [***]
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (intermediate users)

Post by kim »

Yes I'm happy I did the work...

I have never used "PushBullet"
so I'm not sure... but I'd start with

If you look in code:

Code: Select all

if (pushover || pushbullet ) { include('lib/web') }
so download this file and place it in the lib folder where you have my amc script

Code: Select all

https://github.com/filebot/scripts/blob/master/lib/web.groovy
https://raw.githubusercontent.com/fileb ... web.groovy
Jerome2626
Posts: 25
Joined: 07 Mar 2020, 12:56

Re: Unofficial custom AMC script (intermediate users)

Post by Jerome2626 »

Hi would it be possible to have the AMC command for actor download for movies and episodes?
kim
Power User
Posts: 1251
Joined: 15 May 2014, 16:17

Re: Unofficial custom AMC script (intermediate users)

Post by kim »

I have no plan on adding download of actors, because KODI does it by default when it has the url like so:

Code: Select all

  <actor>
    <name>Sylvester Stallone</name>
    <role>John Rambo</role>
    <order>0</order>
    <thumb>https://image.tmdb.org/t/p/original/4E6RSJNsiyhAD8XTq0S3Vwxn62x.jpg</thumb>
  </actor>
you can save (export) all the files with KODI
https://kodi.wiki/view/Import-export_library

https://kodi.wiki/view/Import-export_library/Video

btw: you can make your own script that downloads the files
Post Reply