Skip to content

Instantly share code, notes, and snippets.

@soywiz
Last active April 17, 2024 02:53
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save soywiz/2c10feb1231e70aca19a58aca9d6c16a to your computer and use it in GitHub Desktop.
Save soywiz/2c10feb1231e70aca19a58aca9d6c16a 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
}
@soywiz
Copy link
Author

soywiz commented Sep 13, 2019

Java version:

import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.parsers.DocumentBuilderFactory;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class VsMeta {
    static public void main(String[] args) {
        try {
            for (final File folder : new File("/Folder/to/tvseries").listFiles()) {
            //for (final File folder : new File("/Volumes/iMac-Data/temp/").listFiles()) {
                if (!folder.isDirectory()) continue;
                processFolder(folder);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static public void processFolder(File folder) throws IOException {
        File tvShowNfoFile = new File(folder, "tvshow.nfo");
        System.out.println(tvShowNfoFile);
        if (!tvShowNfoFile.exists()) {
            System.out.println("    --> Not Exists");
            return;
        }
        TvShowNfo.Info showNfo = readTvShowNfo(tvShowNfoFile);
        //val vsMeta = showNfo.toVsMeta(folder = folder)


        for (Path path : Files.walk(Paths.get(folder.toURI())).collect(Collectors.toList())) {
            File file = path.toFile();
            Integer season = null;
            Integer episode = null;
            if (episode == null) {
                Matcher SEResult = Pattern.compile("S(\\d+)E(\\d+)").matcher(file.getName());
                if (SEResult.find()) {

                    season = Integer.parseInt(SEResult.group(1));
                    episode = Integer.parseInt(SEResult.group(2));
                }
            }

            if (episode == null) {
                Matcher SEResult = Pattern.compile("(\\d+)x(\\d+)").matcher(file.getName());
                if (SEResult.find()) {
                    season = Integer.parseInt(SEResult.group(1));
                    episode = Integer.parseInt(SEResult.group(2));
                }
            }
            if (episode == null) {
                Matcher SEResult = Pattern.compile("[\\-_ ]\\s*(\\d{2,3})").matcher(file.getName());
                if (SEResult.find()) {
                    season = 1;
                    episode = Integer.parseInt(SEResult.group(1));
                }
            }


            File vsmetaFile = new File(file.getParentFile(), file.getName() + ".vsmeta");
            VsMeta.Info vsmeta = new VsMeta.Info();
            if (vsmetaFile.exists()) {
                try {
                    vsmeta = readVsMeta(vsmetaFile);
                } catch (Throwable e) {
                    e.printStackTrace();
                }
            }

            //println(":::::::::::")
            //println(folder)
            //println(file)
            //println(vsmetaFile)
            //println(vsmeta)
            //println(showNfo)
            //println("season: $season")
            //println("episode: $episode")

            VsMeta.Info newMeta = toVsMeta(showNfo, episode, season, new Info(), folder);
            fileWriteAll(vsmetaFile, newMeta.serialize());
        }
        //println(showNfo)
        //println(vsMeta)
        //folder["tvshow.vsmeta"].writeBytes(vsMeta.serialize())
    }

    static public TvShowNfo.Info readTvShowNfo(File file) {
        return TvShowNfo.parse(new String(fileReadAll(file), StandardCharsets.UTF_8));
    }


    static public final int TAG_SHOW_TITLE = 0x12;
    static public final int TAG_SHOW_TITLE2 = 0x1A;
    static public final int TAG_EPISODE_TITLE = 0x22;
    static public final int TAG_YEAR = 0x28;
    static public final int TAG_EPISODE_RELEASE_DATE = 0x32;
    static public final int TAG_EPISODE_LOCKED = 0x38;
    static public final int TAG_CHAPTER_SUMMARY = 0x42;
    static public final int TAG_EPISODE_META_JSON = 0x4A;
    static public final int TAG_GROUP1 = 0x52;
    static public final int TAG_CLASSIFICATION = 0x5A;
    static public final int TAG_RATING = 0x60;

    static public final int TAG_EPISODE_THUMB_DATA = 0x8a;
    static public final int TAG_EPISODE_THUMB_MD5 = 0x92;

    static public final int TAG_GROUP2 = 0x9a;

    static public final int TAG1_CAST = 0x0A;
    static public final int TAG1_DIRECTOR = 0x12;
    static public final int TAG1_GENRE = 0x1A;
    static public final int TAG1_WRITER = 0x22;

    static public final int TAG2_SEASON = 0x08;
    static public final int TAG2_EPISODE = 0x10;
    static public final int TAG2_TV_SHOW_YEAR = 0x18;
    static public final int TAG2_RELEASE_DATE_TV_SHOW = 0x22;
    static public final int TAG2_LOCKED = 0x28;
    static public final int TAG2_TVSHOW_SUMMARY = 0x32;
    static public final int TAG2_POSTER_DATA = 0x3A;
    static public final int TAG2_POSTER_MD5 = 0x42;
    static public final int TAG2_TVSHOW_META_JSON = 0x4A;
    static public final int TAG2_GROUP3 = 0x52;

    static public final int TAG3_BACKDROP_DATA = 0x0a;
    static public final int TAG3_BACKDROP_MD5 = 0x12;
    static public final int TAG3_TIMESTAMP = 0x18;
    //fun SyncStream.readVlString()

    static class Info {
        public String showTitle = "";
        public String showTitle2 = null;
        public String episodeTitle = "";
        public int year = 2019;
        public Date episodeReleaseDate = null;
        public Date tvshowReleaseDate = null;
        public int tvshowYear = 2019;
        public String tvshowSummary = "";
        public String chapterSummary = "";
        public String classification = "";
        public int season = 1;
        public int episode = 1;
        public Double rating = null;
        public ListInfo list = new ListInfo();
        public ImageInfo images = new ImageInfo();
        public String tagEpisodeMetaJson = "null";
        public String tagTvshowMetaJson = "null";
        public Date timestamp = new Date();
        public boolean episodeLocked = true;
        public boolean tvshowLocked = true;

        public byte[] serialize() {
            return generate(this);
        }
    }

    static class ListInfo {
        public Set<String> cast = new HashSet<>();
        public Set<String> genre = new HashSet<>();
        public Set<String> director = new HashSet<>();
        public Set<String> writer = new HashSet<>();
    }

    static class ImageInfo {
        public byte[] tvshowPoster = null;
        public byte[] episodeImage = null;
        public byte[] tvshowBackdrop = null;
    }

    static public byte[] MemorySyncStreamToByteArray(Consumer<SyncStream> handler) {
        MemorySyncStream mss = new MemorySyncStream(new byte[0]);
        handler.accept(mss);
        return mss.toByteArray();
    }

    static void writeTag(SyncStream that, int tag, String v) {
        that.writeU_VL_Int(tag);
        that.writeStringVL(v, StandardCharsets.UTF_8);
    }

    static void writeTag(SyncStream that, int tag, byte[] v) {
        that.writeU_VL_Int(tag);
        that.writeBytesVL(v);
    }

    static void writeTag(SyncStream that, int tag, int v) {
        that.writeU_VL_Int(tag);
        that.writeU_VL_Int(v);
    }

    static void writeTag(SyncStream that, int tag, long v) {
        that.writeU_VL_Int(tag);
        that.writeU_VL_Long(v);
    }

    static void writeTag(SyncStream that, int tag, Consumer<SyncStream> callback) {
        writeTag(that, tag, MemorySyncStreamToByteArray(callback::accept));
    }

    static void writeTag(SyncStream that, int tag, Date date) {
        writeTag(that, tag, FORMAT_DATE.format(date));
    }

    static void writeTag(SyncStream that, int tag, boolean v) {
        writeTag(that, tag, v ? 1 : 0);
    }

    static SimpleDateFormat FORMAT_DATE = new SimpleDateFormat("yyyy-MM-dd");

    static byte[] generate(Info info) {
        return MemorySyncStreamToByteArray((that) -> {
            write(that, info);
        });
    }

    static void write(SyncStream that, Info info) {
        that.write8(0x08);
        that.write8(0x02);
        writeTag(that, TAG_SHOW_TITLE, (info.showTitle2 != null) ? info.showTitle2 : info.showTitle);
        writeTag(that, TAG_SHOW_TITLE2, (info.showTitle2 != null) ? info.showTitle2 : info.showTitle);
        writeTag(that, TAG_EPISODE_TITLE, info.episodeTitle);
        writeTag(that, TAG_YEAR, info.year);
        if (info.episodeReleaseDate != null) writeTag(that, TAG_EPISODE_RELEASE_DATE, info.episodeReleaseDate);
        writeTag(that, TAG_EPISODE_LOCKED, info.episodeLocked ? 1 : 0);
        writeTag(that, TAG_CHAPTER_SUMMARY, info.chapterSummary);
        writeTag(that, TAG_EPISODE_META_JSON, info.tagEpisodeMetaJson);
        writeTag(that, TAG_GROUP1, (t) -> {
            writeGroup1(t, info);
        });
        writeTag(that, TAG_CLASSIFICATION, info.classification);
        writeTag(that, TAG_RATING, (info.rating == null) ? -1 : (int) (info.rating.intValue() * 10));
        if (info.images.episodeImage != null) {
            writeTag(that, TAG_EPISODE_THUMB_DATA, toBase64Split(info.images.episodeImage));
            writeTag(that, TAG_EPISODE_THUMB_MD5, hex(md5(info.images.episodeImage)));
        }
        writeTag(that, TAG_GROUP2, (t) -> {
            writeGroup2(t, info);
        });
    }

    static void writeGroup1(SyncStream that, Info info) {
        for (String v : info.list.cast) writeTag(that, TAG1_CAST, v);
        for (String v : info.list.genre) writeTag(that, TAG1_GENRE, v);
        for (String v : info.list.director) writeTag(that, TAG1_DIRECTOR, v);
        for (String v : info.list.writer) writeTag(that, TAG1_WRITER, v);
    }

    static void writeGroup2(SyncStream that, Info info) {
        writeTag(that, TAG2_SEASON, info.season);
        writeTag(that, TAG2_EPISODE, info.episode);
        writeTag(that, TAG2_TV_SHOW_YEAR, info.tvshowYear);
        if (info.tvshowReleaseDate != null) writeTag(that, TAG2_RELEASE_DATE_TV_SHOW, info.tvshowReleaseDate);
        writeTag(that, TAG2_LOCKED, info.tvshowLocked);
        writeTag(that, TAG2_TVSHOW_SUMMARY, info.tvshowSummary);
        if (info.images.tvshowPoster != null) {
            writeTag(that, TAG2_POSTER_DATA, toBase64Split(info.images.tvshowPoster));
            writeTag(that, TAG2_POSTER_MD5, hex(md5(info.images.tvshowPoster)));
        }
        writeTag(that, TAG2_TVSHOW_META_JSON, info.tagTvshowMetaJson);
        writeTag(that, TAG2_GROUP3, (t) -> {
            writeGroup3(t, info);
        });
    }

    static void writeGroup3(SyncStream that, Info info) {
        if (info.images.tvshowBackdrop != null) {
            writeTag(that, TAG3_BACKDROP_DATA, toBase64Split(info.images.tvshowBackdrop));
            writeTag(that, TAG3_BACKDROP_MD5, hex(md5(info.images.tvshowBackdrop)));
        }
        writeTag(that, TAG3_TIMESTAMP, info.timestamp.getTime() / 1000);
    }

    static public Info parse(SyncStream s, Info info) {
        int magic = s.readU8();
        int version = s.readU8();
        if (magic != 0x08) throw new RuntimeException("Not a vsmeta archive");
        if (version != 0x02) throw new RuntimeException("Only supported vsmeta version 2");
        //position = 0x2a
        //position = 0xd2

        while (!s.eof()) {
            long pos = s.position();
            int kind = s.readU_VL_Int();
            switch (kind) {
                case TAG_SHOW_TITLE:
                    info.showTitle = s.readStringVL();
                    break;
                case TAG_SHOW_TITLE2:
                    info.showTitle2 = s.readStringVL();
                    break;
                case TAG_EPISODE_TITLE:
                    info.episodeTitle = s.readStringVL();
                    break;
                case TAG_YEAR:
                    info.year = s.readU_VL_Int();
                    break;
                case TAG_EPISODE_RELEASE_DATE:
                    try {
                        info.episodeReleaseDate = FORMAT_DATE.parse(s.readStringVL());
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                    break;
                case TAG_EPISODE_LOCKED:
                    info.episodeLocked = s.readU_VL_Int() != 0;
                    break;
                case TAG_CHAPTER_SUMMARY:
                    info.chapterSummary = s.readStringVL();
                    break;
                case TAG_EPISODE_META_JSON:
                    info.tagEpisodeMetaJson = s.readStringVL();
                    break;
                case TAG_GROUP1:
                    parseGroup(openSync(s.readBytesVL()), info);
                    break;
                case TAG_CLASSIFICATION:
                    info.classification = s.readStringVL();
                    break;
                case TAG_RATING:
                    final int it = s.readU_VL_Int();
                    if (it < 0) {
                        info.rating = null;
                    } else {
                        info.rating = ((double) it) / 10;
                    }
                    break;
                case TAG_EPISODE_THUMB_DATA:
                    info.images.episodeImage = fromBase64IgnoreSpaces(s.readStringVL());
                    break;
                case TAG_EPISODE_THUMB_MD5:
                    assert (hex(md5(info.images.episodeImage)).equals(s.readStringVL()));
                    break;
                case TAG_GROUP2: {
                    int dataSize = s.readU_VL_Int();
                    long pos2 = s.position();
                    byte[] data = s.readBytes(dataSize);
                    parseGroup2(openSync(data), info, (int) pos2);
                    break;
                }
                default: {
                    throw new RuntimeException("[MAIN] Unexpected kind=${kind.hex} at ${pos.toInt().hex}");
                }
            }
        }

        return info;
    }

    static SyncStream parseGroup(SyncStream s, Info info) {
        while (!s.eof()) {
            long pos = s.position();
            int kind = s.readU_VL_Int();
            switch (kind) {
                case TAG1_CAST:
                    info.list.cast.add(s.readStringVL());
                    break;
                case TAG1_DIRECTOR:
                    info.list.director.add(s.readStringVL());
                    break;
                case TAG1_GENRE:
                    info.list.genre.add(s.readStringVL());
                    break;
                case TAG1_WRITER:
                    info.list.writer.add(s.readStringVL());
                    break;
                default:
                    throw new RuntimeException("[GROUP1] Unexpected kind=${kind.hex} at ${pos.toInt().hex}");
            }
        }
        return s;
    }

    static SyncStream parseGroup2(SyncStream s, Info info, int start) {
        while (!s.eof()) {
            long pos = s.position();
            int kind = s.readU_VL_Int();
            switch (kind) {
                case TAG2_SEASON:
                    info.season = s.readU_VL_Int();
                    break;
                case TAG2_EPISODE:
                    info.episode = s.readU_VL_Int();
                    break;
                case TAG2_TV_SHOW_YEAR:
                    info.tvshowYear = s.readU_VL_Int();
                    break;
                case TAG2_RELEASE_DATE_TV_SHOW:
                    try {
                        info.tvshowReleaseDate = FORMAT_DATE.parse(s.readStringVL());
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                    break;
                case TAG2_LOCKED:
                    info.tvshowLocked = s.readU_VL_Int() != 0;
                    break;
                case TAG2_TVSHOW_SUMMARY:
                    info.tvshowSummary = s.readStringVL();
                    break;
                case TAG2_POSTER_DATA:
                    info.images.tvshowPoster = fromBase64IgnoreSpaces(s.readStringVL());
                    break;
                case TAG2_POSTER_MD5:
                    assert (s.readStringVL().equals(hex(md5(info.images.tvshowPoster))));
                    break;
                case TAG2_TVSHOW_META_JSON:
                    info.tagTvshowMetaJson = s.readStringVL();
                    break;
                case TAG2_GROUP3: { // GROUP3
                    int dataSize = s.readU_VL_Int();
                    int start2 = (int) s.position();
                    byte[] data = s.readBytes(dataSize);
                    parseGroup3(openSync(data), info, start2 + start);
                    //val picture2Base64 = com.soywiz.util.readStringVL()
                    break;
                }
                default:
                    throw new RuntimeException("[GROUP2] Unexpected kind=${kind.hex} at ${(start + pos).toInt().hex}");
            }
        }
        return s;
    }

    static SyncStream openSync(byte[] data) {
        return new MemorySyncStream(data);
    }

    static SyncStream parseGroup3(SyncStream s, Info info, int start) {
        while (!s.eof()) {
            long pos = s.position();
            int kind = s.readU_VL_Int();
            switch (kind) {
                case TAG3_BACKDROP_DATA:
                    info.images.tvshowBackdrop = fromBase64IgnoreSpaces(s.readStringVL());
                    break;
                case TAG3_BACKDROP_MD5:
                    assert (s.readStringVL().equals(hex(md5(info.images.tvshowBackdrop))));
                    break;
                case TAG3_TIMESTAMP:
                    info.timestamp = new Date(s.readU_VL_Long() * 1000L);
                    break;
                default:
                    throw new RuntimeException("[GROUP3] Unexpected kind=${kind.hex} at ${(start + pos).toInt().hex}");
            }
        }
        return s;
    }

    //////////////////////////////////

    static public Info readVsMeta(File that) {
        return parse(new MemorySyncStream(fileReadAll(that)), new Info());
    }

    static public VsMeta.Info toVsMeta(
            TvShowNfo.Info that
    ) {
        return toVsMeta(that, null, null, new VsMeta.Info(), new File("/dummy"));
    }

    static public VsMeta.Info toVsMeta(
            TvShowNfo.Info that,
            Integer episode,
            Integer season,
            VsMeta.Info base,
            File folder
    ) {
        base.showTitle = that.title;
        base.showTitle2 = (that.sortTitle == null || that.sortTitle.length() == 0) ? that.title : that.sortTitle;
        //base.episodeTitle = ""
        base.year = (that.premiered != null) ? 1900 + that.premiered.getYear() : 0;
        base.episodeReleaseDate = that.premiered;
        base.tvshowReleaseDate = that.premiered;
        base.tvshowYear = (that.premiered != null) ? 1900 + that.premiered.getYear() : 0;
        base.tvshowSummary = that.plot;
        //base.chapterSummary = ""
        if (that.mpaa != null) base.classification = that.mpaa;
        if (season != null) base.season = season;
        if (episode != null) base.episode = episode;
        base.rating = that.rating;
        base.list.cast.addAll(that.actors);
        base.list.cast.remove("");
        base.list.genre.addAll(that.genres);
        base.list.genre.remove("");
        base.list.director.addAll(that.studios);
        base.list.director.remove("");
        if (new File(folder, "poster.jpg").exists()) {
            //println("Included poster!")
            base.images.tvshowPoster = fileReadAll(new File(folder, "poster.jpg"));
        }
        if (new File(folder, "fanart.jpg").exists()) {
            //println("Included fanart!")
            base.images.tvshowBackdrop = fileReadAll(new File(folder, "fanart.jpg"));
        }
        base.tagEpisodeMetaJson = "{\n   \"com.synology.FileAssets\" : {}\n}";
        base.tagTvshowMetaJson = "{\n   \"com.synology.FileAssets\" : {}\n}";
        base.timestamp = (that.dateAdded != null) ? that.dateAdded : new Date();
        //base.episodeLocked = true
        //base.tvshowLocked = true
        return base;
    }

    static public class TvShowNfo {
        static class Info {
            String title = "";
            String sortTitle = null;
            String mpaa = null;
            String plot = "";
            Double rating = null;
            Integer votes = null;
            String imdbid = null;
            Date premiered = null;
            Date dateAdded = null;
            List<String> genres = new ArrayList<>();
            List<String> studios = new ArrayList<>();
            List<String> actors = new ArrayList<>();
        }

        static Info parse(String xml) {
            try {
                return parse(DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getDocumentElement());
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        }

        /*
        val Iterable<Xml>.text get() = firstOrNull()?.text
        val Iterable<Xml>.int get() = text?.toInt()
        val Iterable<Xml>.double get() = text?.toDouble()
        */

        static String getChildText(Element node, String name, String _default) {
            NodeList list = node.getElementsByTagName(name);
            if (list == null) return _default;
            Node item = list.item(0);
            if (item == null) return _default;
            return item.getTextContent();
        }

        static List<String> getChildTextList(Element node, String name) {
            NodeList list = node.getElementsByTagName(name);
            if (list == null) return new ArrayList<>();
            ArrayList<String> out = new ArrayList<>();
            for (int n = 0; n < list.getLength(); n++) {
                Node item = list.item(n);
                out.add(item.getTextContent());
            }
            return out;
        }

        static int getChildInt(Element node, String name, int _default) {
            String result = getChildText(node, name, null);
            if (result == null) return _default;
            return Integer.parseInt(result);
        }

        static double getChildDouble(Element node, String name, double _default) {
            String result = getChildText(node, name, null);
            if (result == null) return _default;
            return Double.parseDouble(result);
        }

        static public Info parse(Element xml) {
            Info info = new Info();
            assert (Objects.equals(xml.getNodeName(), "tvshow"));

            info.title = getChildText(xml, "title", "");
            info.sortTitle = getChildText(xml, "sorttitle", null);
            int year = getChildInt(xml, "year", 0);
            info.rating = getChildDouble(xml, "rating", 0.0);
            info.votes = getChildInt(xml, "votes", 0);
            info.plot = getChildText(xml, "plot", "");
            info.mpaa = getChildText(xml, "mpaa", null);
            info.imdbid = getChildText(xml, "imdbid", "");
            try {
                info.premiered = FORMAT_DATE.parse(getChildText(xml, "premiered", ""));
            } catch (Throwable e) {
                e.printStackTrace();
                info.premiered = new Date(year, Calendar.JANUARY, 1);
            }
            try {
                info.dateAdded = FORMAT_DATE.parse(getChildText(xml, "dateadded", ""));
            } catch (Throwable e) {
                e.printStackTrace();
                info.dateAdded = new Date();
            }
            info.genres = getChildTextList(xml, "genre");
            info.studios = getChildTextList(xml, "studio");
            info.actors = new ArrayList<>();
            for (Element actor : elements(xml.getElementsByTagName("actor"))) {
                info.actors.add(getChildText(actor, "name", ""));
            }
            return info;
        }

        static List<Element> elements(NodeList nodeList) {
            List<Element> out = new ArrayList<>();
            if (nodeList != null) {
                for (int n = 0; n < nodeList.getLength(); n++) {
                    Node item = nodeList.item(n);
                    if (item instanceof Element) {
                        out.add((Element) item);
                    }
                }
            }
            return out;
        }
    }

    static public interface SyncInputStream {
    }

    static public interface SyncOutputStream {
    }

    static public class MemorySyncStream extends SyncStream {
        int position;
        int length;
        byte[] data;

        public MemorySyncStream(byte[] data) {
            this(data, 0);
        }

        public MemorySyncStream(byte[] data, int position) {
            this.data = data;
            this.position = position;
            this.length = data.length;
        }

        @Override
        long position() {
            return position;
        }

        @Override
        long length() {
            return length;
        }

        @Override
        public int readU8() {
            if (eof()) return -1;
            return this.data[position++];
        }

        @Override
        public void write8(int value) {
            if (position >= length) {
                length++;
                if (length > data.length) {
                    data = Arrays.copyOf(data, 7 + data.length * 3);
                }
            }
            this.data[position++] = (byte) value;
        }

        public byte[] toByteArray() {
            return Arrays.copyOf(this.data, this.length);
        }

    }

    static public abstract class SyncStream implements SyncInputStream, SyncOutputStream {
        abstract long position();

        abstract long length();

        boolean eof() {
            return position() >= length();
        }

        boolean hasMore() {
            return !eof();
        }

        long available() {
            return length() - position();
        }

        abstract public int readU8();

        abstract public void write8(int value);

        public void writeBytes(byte[] data) {
            for (int n = 0; n < data.length; n++) write8(data[n]);
        }

        public void readExact(byte[] data, int offset, int length) {
            for (int n = 0; n < length; n++) {
                int v = readU8();
                data[offset + n] = (byte) v;
            }
        }

        public byte[] readBytes(int count) {
            byte[] out = new byte[Math.min(count, (int) available())];
            readExact(out, 0, out.length);
            return out;
        }

        public void writeU_VL_Int(int value) {
            writeU_VL_Long((long) value);
        }

        public void writeU_VL_Long(long value) {
            long v = value;
            do {
                int data = (int) (v & 0x7F);
                v = v >>> 7;
                boolean hasMore = v != 0L;
                int data2 = (hasMore) ? 0x80 : 0x00;
                write8(data | data2);
            } while (hasMore());
        }

        public int readU_VL_Int() {
            return (int) readU_VL_Long();
        }

        public long readU_VL_Long() {
            long out = 0L;
            int offset = 0;
            int v;
            do {
                v = readU8();
                out = out | ((long) (v & 0x7F) << offset);
                offset += 7;
            } while ((v & 0x80) != 0);
            return out;
        }

        public void writeBytesVL(byte[] data) {
            writeU_VL_Int(data.length);
            writeBytes(data);
        }

        public void writeStringVL(String str, Charset charset) {
            writeBytesVL(str.getBytes(charset));
        }

        public byte[] readBytesVL() {
            byte[] bytes = new byte[readU_VL_Int()];
            readExact(bytes, 0, bytes.length);
            return bytes;
        }

        public String readStringVL() {
            return readStringVL(StandardCharsets.UTF_8);
        }

        public String readStringVL(Charset charset) {
            return new java.lang.String(readBytesVL(), charset);
        }
    }


    static byte[] fromBase64IgnoreSpaces(String str) {
        return Base64.getDecoder().decode(str.replaceAll("\\s+", ""));
    }

    static String toBase64Split(byte[] that) {
        return Base64.getEncoder().encodeToString(that);
    }

    static String toBase64Split(byte[] that, int width) {
        return String.join("\n", splitEqually(toBase64Split(that), width));
    }

    public static List<String> splitEqually(String text, int size) {
        // Give the list the right capacity to start with. You could use an array
        // instead if you wanted.
        List<String> ret = new ArrayList<String>((text.length() + size - 1) / size);

        for (int start = 0; start < text.length(); start += size) {
            ret.add(text.substring(start, Math.min(text.length(), start + size)));
        }
        return ret;
    }

    static public byte[] md5(byte[] data) {
        try {
            if (data == null) data = new byte[0];
            return MessageDigest.getInstance("MD5").digest(data);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    @SuppressWarnings("PointlessArithmeticExpression")
    static public String hex(byte[] data) {
        final String chars = "0123456789abcdef";
        char[] out = new char[data.length * 2];
        for (int n = 0; n < data.length; n++) {
            int v = data[n];
            out[n * 2 + 0] = chars.charAt((v >> 4) & 0xF);
            out[n * 2 + 1] = chars.charAt((v >> 0) & 0xF);
        }
        return new String(out);
    }

    static public byte[] fileReadAll(File file) {
        byte[] out = new byte[(int) file.length()];
        try {
            FileInputStream fis = new FileInputStream(file);
            int offset = 0;
            while (offset < out.length) {
                int read = fis.read(out, offset, out.length - offset);
                if (read < 0) break;
                offset += read;
            }
            return out;
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }

    static public void fileWriteAll(File file, byte[] data) {
        try (FileChannel channel = new FileOutputStream(file).getChannel()) {
            channel.write(ByteBuffer.wrap(data));
            channel.truncate(data.length);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment