Last active
April 17, 2024 02:53
-
-
Save soywiz/2c10feb1231e70aca19a58aca9d6c16a to your computer and use it in GitHub Desktop.
VideoStation's VsMeta File Format (released as PUBLIC DOMAIN) https://unlicense.org/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
Java version: