npm.io
0.2.1 • Published yesterday

sacha

Licence
MIT
Version
0.2.1
Deps
0
Size
1.0 MB
Vulns
0
Weekly
0

Sacha

TL;DR A web/improved version of anitomy

Sacha is a media string parser and more, for filenames, torrent names, or any sort of strings that represent a media name/metadata through a string and formats everything neatly for you.

Examples:

Sacha can parse all sorts of formats:

[silly] Cyberpunk Edgerunners (WEB-DL 1080p HEVC E-AC-3) [Dual-Audio]
Cyberpunk.Edgerunners.S01.1080p.NF.WEB-DL.DDP5.1.DV.HDR.H.265.HUN.JPN.ENG-VARYG (DUAL, Multi-sub)
Ni Zhenshi Ge Tiancai - 15 - 1080p WEB H.264 -NanDesuKa (B-Global).mkv
Initial D - Third Stage (High Quality) MKV [1080p] Blu-Ray Rip (Stabilized V2)
[Erai-raws] Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2 - 02 [480p][Multiple Subtitle][5F5B5979].mkv
const result = parse('[Erai-raws] Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2 - 12 [1080p][HEVC][Multiple Subtitle] [ENG][POR-BR][SPA-LA][SPA][ARA][FRE][GER][ITA]')
expect(result, {
  group: 'Erai-raws',
  title: 'Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e',
  season: 2,
  episode: 12,
  resolution: '1080p',
  videoCodec: 'HEVC',
  subtitleTerms: ['Multiple Subtitle'],
  subtitleLanguages: ['ENG', 'POR-BR', 'SPA-LA', 'SPA', 'ARA', 'FRE', 'GER', 'ITA']
})

const formattedResults = format(result)
expect(formattedResults, {
  /** Release group name */
  group: 'Erai-raws',
  /** Title of the media */
  title: 'Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e',
  /** Season number */
  season: 2,
  /** Episode number */
  episode: 12,
  /** Common resolution, can be undefined as inferring doesn't make sense */
  resolution: Resolution.FHD, // 1080
  /** Common video codec, can be undefined */
  videoCodec: VideoCodec.H265, // 'H265'
  subtitleTerms: ['Multiple Subtitle'],
  subtitleLanguages: [
    LanguageTag.EN // 'en' ISO 639-2 Code for English
    LanguageTag.PT // 'pt' Code for Portuguese
    LanguageTag.ES // 'es' Code for Spanish
    LanguageTag.AR // 'ar' Code for Arabic
    LanguageTag.FR // 'fr' Code for French
    LanguageTag.DE // 'de' Code for German
    LanguageTag.IT // 'it' Code for Italian
  ]
})

const inferredResults = infer(result, { type: 'ANIME' })
expect(inferredResults, {
  group: 'Erai-raws',
  title: 'Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e',
  // ...
  /** Listed languages or inferred languages */
  audioLanguages: [
    LanguageTag.JA // 'ja' ISO 639-2 Code for Japanese
  ]
})
const result = parse('[silly] Cyberpunk Edgerunners (WEB-DL 1080p HEVC E-AC-3) [Dual-Audio]')
expect(result, {
  group: 'silly',
  title: 'Cyberpunk Edgerunners',
  type: 'WEB-DL',
  resolution: '1080p',
  videoCodec: 'HEVC',
  audioCodec: 'E-AC-3',
  audioTerms: ['Dual-Audio']
})

const formattedResults = format(result)
expect(formattedResults, {
  group: 'silly',
  title: 'Cyberpunk Edgerunners',
  /** Type of release, Web, BlueRay, ect... */
  type: ReleaseType.WEB,
  resolution: Resolution.FHD,
  videoCodec: VideoCodec.H265, // 'H265'
  /** Common audio codec, can be undefined */
  audioCodec: AudioCodec.EAC3, // 'EAC3'
  /** Various audio terms that can help inferring languages, quality, ect... */
  audioTerms: ['Dual-Audio'],
})


const inferredResults = infer(result, { type: 'ANIME' })
expect(inferredResults, {
  group: 'silly',
  title: 'Cyberpunk Edgerunners',
  season: 1,
  /** Episode range describing how many episodes there is, if unknown infer Infinity */
  episodes: [1, Infinity],
  /** If a batch release, meaning if there is multiple files included in that (torrent/archive) file */
  batch: true,
  type: ReleaseType.WEB,
  resolution: Resolution.FHD,
  videoCodec: VideoCodec.H265,
  audioCodec: AudioCodec.EAC3,
  audioTerms: ['Dual-Audio'],
  audioLanguages: [
    LanguageTag.JA
    LanguageTag.EN
  ]
})
const result = parse('[MTBB] Made in Abyss S2 - The Golden City of the Scorching Sun - 04')
expect(result, {
  group: 'MTBB',
  title: 'Made in Abyss - The Golden City of the Scorching Sun',
  season: 'S2',
  episode: '04'
})

const formattedResults = format(result)
expect(formattedResults, {
  group: 'MTBB',
  title: 'Made in Abyss - The Golden City of the Scorching Sun',
  season: 2,
  episode: 4
})


const inferredResults = infer(result, { type: 'ANIME' })
expect(inferredResults, {
  group: 'MTBB',
  title: 'Made in Abyss - The Golden City of the Scorching Sun',
  season: 2,
  episode: 4,
  audioLanguages: [LanguageTag.JA]
})

Matching

Sacha bundles frizbee, a SIMD Smith-Waterman matcher, so a scraped list of release names can be parsed and ranked against a title without leaving wasm.

Parsing first is what makes the ranking work. A release name is mostly noise, and the group tag and quality flags compete with the title for alignment. Matching the parsed title candidates instead is both faster and more accurate.

import init, { ReleaseIndex } from 'sacha'

await init()

// parsed once, on construction; the list stays on the wasm side
const index = new ReleaseIndex(scrapedNames)

// pass every alias of the show, not just one
const hits = index.search(
  ['Shingeki no Kyojin', 'Attack on Titan', '進撃の巨人'],
  { limit: 20 }
)
// [{ index: 12, score: 316, title: 'Shingeki no Kyojin', exact: true }, ...]

const release = scrapedNames[hits[0].index]
const metadata = index.parsed(hits[0].index)   // the full ParseResult

Passing several aliases is the point rather than a convenience. A release named only 進撃の巨人 and one named only Attack on Titan are the same show, and no single query reaches both. Each release is scored by its best (alias, title candidate) pair.

For a list you query once and discard, searchNames does the same work without a handle to keep or free:

import { searchNames } from 'sacha'

const hits = searchNames(scrapedNames, ['Shingeki no Kyojin', 'Attack on Titan'], { limit: 20 })

The two cost the same. Keep a ReleaseIndex only when several queries run against one list: parsing is the whole cost and the index pays it once, so a repeat search is about 0.03 ms where the first is a few milliseconds.

Options
option default meaning
maxTypos 0 Query characters allowed to go unmatched. null scores every entry instead of dropping those that do not contain the query in order.
minScore 1 Smallest score worth returning.
limit 0 Cap on returned rows; 0 means no cap.

maxTypos: 0 filters and is roughly six times faster; maxTypos: null ranks the whole list and never silently drops a candidate. Use null when you want a score for everything and 0 when you want the releases that plainly match.

Scores are raw Smith-Waterman totals, not normalized. They rank candidates against one query well, but are not comparable across queries of different lengths: a longer title scores higher simply for being longer. To compare across queries, divide by the query's score against itself.

Queries are capped at maxQueryLen() characters (3639 under the default scoring) because frizbee aborts beyond that, which in wasm takes the whole module down. Over-long queries throw instead.

Accuracy

Against 1175 real nyaa.si names, 50 of them Attack on Titan releases and the rest unrelated, searching the four aliases above returns 50 of 50 with no false positives.

Cost

Parsing is the whole cost; building the index adds nothing measurable, and matching an already-parsed list is a rounding error. Measured in wasm under node, one query over a freshly parsed list:

names parse and search search again on the same index
50 1.1 ms 0.02 ms
100 2.2 ms 0.03 ms
1175 25 ms 2.0 ms

Cost is linear in the number of names, so a list of a few dozen is a couple of milliseconds.

The repeat-search column assumes the default maxTypos: 0. Setting it to null scores every entry rather than prefiltering, which costs tens of times more per search: on the 1175 name list, one query goes from about 0.03 ms to about 0.9 ms. Still small beside the parse, but not free, so prefer the default when a query plainly appears in the names you are matching.

Todos:
  • make a system that takes all terms, sort them by length, apply them, and re-categorize them back to prevent issues with small terms overriding longer ones
  • format() currently returns the same shape as parse(), and infer() does not exist yet; the examples above describe the intended API rather than the current one

Keywords