Skip to content

Instantly share code, notes, and snippets.

@o314
Forked from soywiz/TvShowNfo.kt
Created January 17, 2022 22:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save o314/ff270044fb5a43b5f03c965eba8524c4 to your computer and use it in GitHub Desktop.
Save o314/ff270044fb5a43b5f03c965eba8524c4 to your computer and use it in GitHub Desktop.
VideoStation's VsMeta File Format (released as PUBLIC DOMAIN) https://unlicense.org/
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
}
group = "com.soywiz"
version = "1.0-SNAPSHOT"
repositories {
maven { url = uri("https://dl.bintray.com/soywiz/soywiz") }
maven { url = uri("https://plugins.gradle.org/m2/") }
mavenCentral()
}
dependencies {
implementation("com.soywiz:korio-jvm:1.6.4")
implementation("com.soywiz:krypto-jvm:1.6.0")
implementation("com.soywiz:klock-jvm:1.2.2")
implementation(kotlin("stdlib-jdk8"))
testImplementation(kotlin("test"))
testImplementation("junit:junit:4.12")
}
tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = "1.8"
}
package com.soywiz
import com.soywiz.korio.*
import com.soywiz.korio.file.*
import com.soywiz.korio.file.std.*
import com.soywiz.util.*
import kotlinx.coroutines.channels.*
import java.io.*
fun main(args: Array<String>) = Korio {
for (folder in localVfs("/Folder/to/tvseries").list().filter { it.isDirectory() }) {
processFolder(folder)
}
}
suspend fun processFolder(folder: VfsFile) {
val tvShowNfoFile = folder["tvshow.nfo"]
println(tvShowNfoFile)
if (!tvShowNfoFile.exists()) {
println(" --> Not Exists")
return
}
val showNfo = tvShowNfoFile.readTvShowNfo()
//val vsMeta = showNfo.toVsMeta(folder = folder)
for (file in folder.listRecursive { it.extensionLC in setOf("mp4", "avi") }) {
var season: Int? = null
var episode: Int? = null
if (episode == null) {
val SEResult = Regex("S(\\d+)E(\\d+)").find(file.baseName)
if (SEResult != null) {
season = SEResult.groupValues[1].toInt()
episode = SEResult.groupValues[2].toInt()
}
}
if (episode == null) {
val SEResult = Regex("(\\d+)x(\\d+)").find(file.baseName)
if (SEResult != null) {
season = SEResult.groupValues[1].toInt()
episode = SEResult.groupValues[2].toInt()
}
}
if (episode == null) {
val SEResult = Regex("[\\-_ ]\\s*(\\d{2,3})").find(file.baseName)
if (SEResult != null) {
season = 1
episode = SEResult.groupValues[1].toInt()
}
}
val vsmetaFile = file.appendExtension("vsmeta")
val vsmeta = if (vsmetaFile.exists()) try {vsmetaFile.readVsMeta() } catch (e: Throwable) { e.printStackTrace(); VsMeta.Info() } else VsMeta.Info()
//println(":::::::::::")
//println(folder)
//println(file)
//println(vsmetaFile)
//println(vsmeta)
//println(showNfo)
//println("season: $season")
//println("episode: $episode")
val newMeta = showNfo.toVsMeta(season = season, episode = episode, base = vsmeta, folder = folder)
vsmetaFile.writeBytes(newMeta.serialize())
}
//println(showNfo)
//println(vsMeta)
//folder["tvshow.vsmeta"].writeBytes(vsMeta.serialize())
}
package com.soywiz.util
import com.soywiz.klock.*
import com.soywiz.korio.file.*
import com.soywiz.korio.serialization.xml.*
class TvShowNfo {
data class Info(
var title: String = "",
var sortTitle: String? = null,
var mpaa: String? = null,
var plot: String = "",
var rating: Double? = null,
var votes: Int? = null,
var imdbid: String? = null,
var premiered: DateTime? = null,
var dateAdded: DateTime? = null,
var genres: List<String> = listOf(),
var studios: List<String> = listOf(),
var actors: List<String> = listOf()
)
companion object {
fun parse(xml: String) = parse(Xml.parse(xml))
val Iterable<Xml>.text get() = firstOrNull()?.text
val Iterable<Xml>.int get() = text?.toInt()
val Iterable<Xml>.double get() = text?.toDouble()
fun parse(xml: Xml): Info {
val info = Info()
check(xml.nameLC == "tvshow")
info.title = xml["title"].text ?: ""
info.sortTitle = xml["sorttitle"].text
val year = xml["year"].int ?: 0
info.rating = xml["rating"].double
info.votes = xml["votes"].int
info.plot = xml["plot"].text ?: ""
info.mpaa = xml["mpaa"].text
info.imdbid = xml["imdbid"].text
info.premiered = try { DateFormat.FORMAT_DATE.parse(xml["premiered"].text!!).utc } catch (e: Throwable) { e.printStackTrace(); DateTime(year, 1, 1) }
info.dateAdded = try { DateFormat.FORMAT_DATE.parse(xml["dateadded"].text!!).utc } catch (e: Throwable) { e.printStackTrace(); DateTime.now() }
info.genres = xml["genre"].map { it.text }
info.studios = xml["studio"].map { it.text }
info.actors = xml["actor"]["name"].map { it.text }
return info
}
}
}
suspend fun VfsFile.readTvShowNfo() = TvShowNfo.parse(this.readString())
package com.soywiz.util
import com.soywiz.klock.*
import com.soywiz.kmem.*
import com.soywiz.korio.file.*
import com.soywiz.korio.file.std.*
import com.soywiz.korio.lang.*
import com.soywiz.korio.stream.*
import com.soywiz.korio.util.encoding.*
import com.soywiz.krypto.*
object VsMeta {
private const val TAG_SHOW_TITLE = 0x12
private const val TAG_SHOW_TITLE2 = 0x1A
private const val TAG_EPISODE_TITLE = 0x22
private const val TAG_YEAR = 0x28
private const val TAG_EPISODE_RELEASE_DATE = 0x32
private const val TAG_EPISODE_LOCKED = 0x38
private const val TAG_CHAPTER_SUMMARY = 0x42
private const val TAG_EPISODE_META_JSON = 0x4A
private const val TAG_GROUP1 = 0x52
private const val TAG_CLASSIFICATION = 0x5A
private const val TAG_RATING = 0x60
private const val TAG_EPISODE_THUMB_DATA = 0x8a
private const val TAG_EPISODE_THUMB_MD5 = 0x92
private const val TAG_GROUP2 = 0x9a
private const val TAG1_CAST = 0x0A
private const val TAG1_DIRECTOR = 0x12
private const val TAG1_GENRE = 0x1A
private const val TAG1_WRITER = 0x22
private const val TAG2_SEASON = 0x08
private const val TAG2_EPISODE = 0x10
private const val TAG2_TV_SHOW_YEAR = 0x18
private const val TAG2_RELEASE_DATE_TV_SHOW = 0x22
private const val TAG2_LOCKED = 0x28
private const val TAG2_TVSHOW_SUMMARY = 0x32
private const val TAG2_POSTER_DATA = 0x3A
private const val TAG2_POSTER_MD5 = 0x42
private const val TAG2_TVSHOW_META_JSON = 0x4A
private const val TAG2_GROUP3 = 0x52
private const val TAG3_BACKDROP_DATA = 0x0a
private const val TAG3_BACKDROP_MD5 = 0x12
private const val TAG3_TIMESTAMP = 0x18
//fun SyncStream.readVlString()
data class Info(
var showTitle: String = "",
var showTitle2: String? = null,
var episodeTitle: String = "",
var year: Int = 2019,
var episodeReleaseDate: DateTime? = null,
var tvshowReleaseDate: DateTime? = null,
var tvshowYear: Int = 2019,
var tvshowSummary: String = "",
var chapterSummary: String = "",
var classification: String = "",
var season: Int = 1,
var episode: Int = 1,
var rating: Double? = null,
var list: ListInfo = ListInfo(),
var images: ImageInfo = ImageInfo(),
var tagEpisodeMetaJson: String = "null",
var tagTvshowMetaJson: String = "null",
var timestamp: DateTime = DateTime.now(),
var episodeLocked: Boolean = true,
var tvshowLocked: Boolean = true
) {
fun serialize() = generate(this)
}
data class ListInfo(
val cast: MutableSet<String> = mutableSetOf(),
val genre: MutableSet<String> = mutableSetOf(),
val director: MutableSet<String> = mutableSetOf(),
val writer: MutableSet<String> = mutableSetOf()
)
class ImageInfo(
var tvshowPoster: ByteArray? = null,
var episodeImage: ByteArray? = null,
var tvshowBackdrop: ByteArray? = null
)
fun SyncStream.writeTag(tag: Int, v: String) = run { writeU_VL_Int(tag); writeStringVL(v) }
fun SyncStream.writeTag(tag: Int, v: ByteArray) = run { writeU_VL_Int(tag); writeBytesVL(v) }
fun SyncStream.writeTag(tag: Int, v: Int) = run { writeU_VL_Int(tag); writeU_VL_Int(v) }
fun SyncStream.writeTag(tag: Int, v: Long) = run { writeU_VL_Int(tag); writeU_VL_Long(v) }
fun SyncStream.writeTag(tag: Int, callback: SyncStream.() -> Unit) = writeTag(tag, MemorySyncStreamToByteArray { callback() })
fun SyncStream.writeTag(tag: Int, date: DateTime) = writeTag(tag, date.format(DateFormat.FORMAT_DATE))
fun SyncStream.writeTag(tag: Int, v: Boolean) = writeTag(tag, v.toInt())
fun generate(info: Info) = MemorySyncStreamToByteArray { this.write(info) }
fun SyncStream.write(info: Info) {
write8(0x08); write8(0x02)
writeTag(TAG_SHOW_TITLE, info.showTitle2 ?: info.showTitle)
writeTag(TAG_SHOW_TITLE2, info.showTitle2 ?: info.showTitle)
writeTag(TAG_EPISODE_TITLE, info.episodeTitle)
writeTag(TAG_YEAR, info.year)
if (info.episodeReleaseDate != null) writeTag(TAG_EPISODE_RELEASE_DATE, info.episodeReleaseDate!!)
writeTag(TAG_EPISODE_LOCKED, info.episodeLocked.toInt())
writeTag(TAG_CHAPTER_SUMMARY, info.chapterSummary)
writeTag(TAG_EPISODE_META_JSON, info.tagEpisodeMetaJson)
writeTag(TAG_GROUP1) { writeGroup1(info) }
writeTag(TAG_CLASSIFICATION, info.classification)
writeTag(TAG_RATING, if (info.rating == null) -1 else (info.rating!! * 10).toInt())
if (info.images.episodeImage != null) {
writeTag(TAG_EPISODE_THUMB_DATA, info.images.episodeImage!!.toBase64Split())
writeTag(TAG_EPISODE_THUMB_MD5, info.images.episodeImage!!.md5().hex)
}
writeTag(TAG_GROUP2) { writeGroup2(info) }
}
fun ByteArray.toBase64Split(width: Int = 76) = this.toBase64().chunked(width).joinToString("\n")
fun SyncStream.writeGroup1(info: Info) {
for (v in info.list.cast) writeTag(TAG1_CAST, v)
for (v in info.list.genre) writeTag(TAG1_GENRE, v)
for (v in info.list.director) writeTag(TAG1_DIRECTOR, v)
for (v in info.list.writer) writeTag(TAG1_WRITER, v)
}
fun SyncStream.writeGroup2(info: Info) {
writeTag(TAG2_SEASON, info.season)
writeTag(TAG2_EPISODE, info.episode)
writeTag(TAG2_TV_SHOW_YEAR, info.tvshowYear)
if (info.tvshowReleaseDate != null) writeTag(TAG2_RELEASE_DATE_TV_SHOW, info.tvshowReleaseDate!!)
writeTag(TAG2_LOCKED, info.tvshowLocked)
writeTag(TAG2_TVSHOW_SUMMARY, info.tvshowSummary)
if (info.images.tvshowPoster != null) {
writeTag(TAG2_POSTER_DATA, info.images.tvshowPoster!!.toBase64Split())
writeTag(TAG2_POSTER_MD5, info.images.tvshowPoster!!.md5().hex)
}
writeTag(TAG2_TVSHOW_META_JSON, info.tagTvshowMetaJson)
writeTag(TAG2_GROUP3) { writeGroup3(info) }
}
fun SyncStream.writeGroup3(info: Info) {
if (info.images.tvshowBackdrop != null) {
writeTag(TAG3_BACKDROP_DATA, info.images.tvshowBackdrop!!.toBase64Split())
writeTag(TAG3_BACKDROP_MD5, info.images.tvshowBackdrop!!.md5().hex)
}
writeTag(TAG3_TIMESTAMP, info.timestamp.unixMillisLong / 1000)
}
fun parse(s: SyncStream, info: Info = Info()): Info {
s.apply {
val magic = readU8()
val version = readU8()
if (magic != 0x08) error("Not a vsmeta archive")
if (version != 0x02) error("Only supported vsmeta version 2")
//position = 0x2a
//position = 0xd2
while (!eof) {
val pos = position
val kind = readU_VL_Int()
when (kind) {
TAG_SHOW_TITLE -> info.showTitle = readStringVL()
TAG_SHOW_TITLE2 -> info.showTitle2 = readStringVL()
TAG_EPISODE_TITLE -> info.episodeTitle = readStringVL()
TAG_YEAR -> info.year = readU_VL_Int()
TAG_EPISODE_RELEASE_DATE -> info.episodeReleaseDate = DateFormat.FORMAT_DATE.parse(readStringVL()).utc
TAG_EPISODE_LOCKED -> info.episodeLocked = readU_VL_Int() != 0
TAG_CHAPTER_SUMMARY -> info.chapterSummary = readStringVL()
TAG_EPISODE_META_JSON -> info.tagEpisodeMetaJson = readStringVL()
TAG_GROUP1 -> parseGroup(
readBytesVL().openSync(),
info
)
TAG_CLASSIFICATION -> info.classification = readStringVL()
TAG_RATING -> info.rating = readU_VL_Int().let { if (it < 0) null else (it.toDouble() / 10) }
TAG_EPISODE_THUMB_DATA -> info.images.episodeImage = readStringVL().fromBase64IgnoreSpaces()
TAG_EPISODE_THUMB_MD5 -> check(info.images.episodeImage?.md5()?.hex == readStringVL())
TAG_GROUP2 -> {
val dataSize = readU_VL_Int()
val pos2 = position
val data = readBytes(dataSize)
parseGroup2(data.openSync(), info, pos2.toInt())
}
else -> {
error("[MAIN] Unexpected kind=${kind.hex} at ${pos.toInt().hex}")
}
}
}
}
return info
}
fun parseGroup(s: SyncStream, info: Info) = s.apply {
while (!eof) {
val pos = position
val kind = readU_VL_Int()
when (kind) {
TAG1_CAST -> info.list.cast.add(readStringVL())
TAG1_DIRECTOR -> info.list.director.add(readStringVL())
TAG1_GENRE -> info.list.genre.add(readStringVL())
TAG1_WRITER -> info.list.writer.add(readStringVL())
else -> {
error("[GROUP1] Unexpected kind=${kind.hex} at ${pos.toInt().hex}")
}
}
}
}
fun parseGroup2(s: SyncStream, info: Info, start: Int) = s.apply {
while (!eof) {
val pos = position
val kind = readU_VL_Int()
when (kind) {
TAG2_SEASON -> info.season = readU_VL_Int()
TAG2_EPISODE -> info.episode = readU_VL_Int()
TAG2_TV_SHOW_YEAR -> info.tvshowYear = readU_VL_Int()
TAG2_RELEASE_DATE_TV_SHOW -> info.tvshowReleaseDate = DateFormat.FORMAT_DATE.parse(readStringVL()).utc
TAG2_LOCKED -> info.tvshowLocked = readU_VL_Int() != 0
TAG2_TVSHOW_SUMMARY -> info.tvshowSummary = readStringVL()
TAG2_POSTER_DATA -> info.images.tvshowPoster = readStringVL().fromBase64IgnoreSpaces()
TAG2_POSTER_MD5 -> check(readStringVL() == info.images.tvshowPoster!!.md5().hex)
TAG2_TVSHOW_META_JSON -> info.tagTvshowMetaJson = readStringVL()
TAG2_GROUP3 -> { // GROUP3
val dataSize = readU_VL_Int()
val start2 = position.toInt()
val data = readBytes(dataSize)
parseGroup3(data.openSync(), info, start2 + start)
//val picture2Base64 = com.soywiz.util.readStringVL()
}
else -> {
error("[GROUP2] Unexpected kind=${kind.hex} at ${(start + pos).toInt().hex}")
}
}
}
}
fun parseGroup3(s: SyncStream, info: Info, start: Int) = s.apply {
while (!eof) {
val pos = position
val kind = readU_VL_Int()
when (kind) {
TAG3_BACKDROP_DATA -> info.images.tvshowBackdrop = readStringVL().fromBase64IgnoreSpaces()
TAG3_BACKDROP_MD5 -> check(readStringVL() == info.images.tvshowBackdrop!!.md5().hex)
TAG3_TIMESTAMP -> info.timestamp = DateTime.fromUnix(readU_VL() * 1000L)
else -> {
error("[GROUP3] Unexpected kind=${kind.hex} at ${(start + pos).toInt().hex}")
}
}
}
}
//////////////////////////////////
}
private fun SyncOutputStream.writeU_VL_Int(value: Int) = writeU_VL_Long(value.toLong())
private fun SyncOutputStream.writeU_VL_Long(value: Long) {
var v = value
do {
val data = (v and 0x7F).toInt()
v = v ushr 7
val hasMore = v != 0L
val data2 = if (hasMore) 0x80 else 0x00
write8(data or data2)
} while (hasMore)
}
private fun SyncInputStream.readU_VL_Int(): Int = readU_VL_Long().toInt()
private fun SyncInputStream.readU_VL_Long(): Long {
var out = 0L
var offset = 0
do {
val v = readU8()
out = out or ((v and 0x7F).toLong() shl offset)
offset += 7
} while ((v and 0x80) != 0)
return out
}
private fun SyncStream.writeBytesVL(data: ByteArray) {
writeU_VL_Int(data.size)
writeBytes(data)
}
private fun SyncStream.writeStringVL(str: String, charset: Charset = UTF8) {
writeBytesVL(str.toByteArray(charset))
}
private fun SyncStream.readBytesVL(): ByteArray {
val bytes = ByteArray(readU_VL_Int())
readExact(bytes, 0, bytes.size)
return bytes
}
private fun SyncStream.readStringVL(charset: com.soywiz.korio.lang.Charset = UTF8): String = readBytesVL().toString(charset)
suspend fun VfsFile.readVsMeta() = VsMeta.parse(this.readAsSyncStream())
suspend fun TvShowNfo.Info.toVsMeta(
episode: Int? = null, season: Int? = null, base: VsMeta.Info = VsMeta.Info(),
folder: VfsFile = MemoryVfs()
): VsMeta.Info {
base.showTitle = title
base.showTitle2 = if (sortTitle.isNullOrBlank()) title else sortTitle
//base.episodeTitle = ""
base.year = premiered?.yearInt ?: 0
base.episodeReleaseDate = premiered
base.tvshowReleaseDate = premiered
base.tvshowYear = premiered?.yearInt ?: 0
base.tvshowSummary = plot
//base.chapterSummary = ""
if (mpaa != null) base.classification = mpaa!!
if (season != null) base.season = season
if (episode != null) base.episode = episode
base.rating = rating
base.list.cast.apply { addAll(actors) }.apply { remove("") }
base.list.genre.apply { addAll(genres) }.apply { remove("") }
base.list.director.apply { addAll(studios) }.apply { remove("") }
if (folder["poster.jpg"].exists()) {
//println("Included poster!")
base.images.tvshowPoster = folder["poster.jpg"].readAll()
}
if (folder["fanart.jpg"].exists()) {
//println("Included fanart!")
base.images.tvshowBackdrop = folder["fanart.jpg"].readAll()
}
base.tagEpisodeMetaJson = "{\n \"com.synology.FileAssets\" : {}\n}"
base.tagTvshowMetaJson = "{\n \"com.synology.FileAssets\" : {}\n}"
base.timestamp = dateAdded ?: DateTime.now()
//base.episodeLocked = true
//base.tvshowLocked = true
return base
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment