/* dvdmap — map a DVD (or a vobcopy --mirror directory) to its titles by
 * walking the disc menus with libdvdnav's VM: every button on every
 * reachable menu is "pressed" and we record which title/chapter it plays.
 * Also reports which titles are "play all" containers of other titles.
 *
 * Usage: dvdmap [-m] [-s] [-c] [-p] [-v] DVD_DIR_OR_DEVICE  (see --help)
 * Build: cc -O2 -o dvdmap dvdmap.c $(pkg-config --cflags --libs dvdnav dvdread)
 *
 * SPDX-License-Identifier: CC0-1.0
 */
#include <getopt.h>
#include <inttypes.h>
#include <setjmp.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <dvdnav/dvdnav.h>
#include <dvdread/dvd_reader.h>
#include <dvdread/ifo_read.h>

#define MAX_TITLES 100
#define MAX_MENUS 64
#define BLOCK_LIMIT 60000          /* ~120 MB of reading per button press */
#define SHORT_TITLE_SECS 60        /* shorter titles are treated as transitions */
#define STUCK_CPU_SECS 2           /* CPU time allowed per button press */

static int verbose, show_menus, show_streams, show_chapters, porcelain;

/* ---------- title table (libdvdread) ---------- */

typedef struct { uint32_t first, last; } cell_t;

/* audio/subtitle streams, numbered the way HandBrake numbers its tracks:
 * the streams the title's PGC marks present, in stream order, deduped */
#define MAX_AUDIO 8
#define MAX_SUBS 32
typedef struct {
    char lang[4];          /* ISO 639-2 where we know it, else 639-1 or "und" */
    const char *codec;     /* audio only */
    int channels;          /* audio only */
    const char *note;      /* commentary, audio description, forced, ... */
} stream_t;

typedef struct {
    int vts, ttn, chapters;
    double secs;
    double *chsecs;                /* length of each chapter, seconds */
    int nchsecs;
    cell_t *cells;
    int ncells;
    int container_of[MAX_TITLES];  /* titles fully contained, in play order */
    int ncontained;
    int contained_in;              /* 0 or the play-all title holding this one */
    const char *video;             /* "NTSC 16:9" etc. */
    stream_t audio[MAX_AUDIO], subs[MAX_SUBS];
    int naudio, nsubs;
} title_t;

static title_t titles[MAX_TITLES + 1];
static int ntitles;

static void quiet_log(void *p, dvd_logger_level_t l, const char *fmt, va_list ap)
{
    (void)p;
    if (verbose || l == DVD_LOGGER_LEVEL_ERROR) {
        vfprintf(stderr, fmt, ap);
        fputc('\n', stderr);
    }
}

static void quiet_navlog(void *p, dvdnav_logger_level_t l, const char *fmt, va_list ap)
{
    (void)p;
    if (verbose || l == DVDNAV_LOGGER_LEVEL_ERROR) {
        vfprintf(stderr, fmt, ap);
        fputc('\n', stderr);
    }
}

static void add_pgc_cells(title_t *t, pgc_t *pgc)
{
    if (!pgc)
        return;
    t->cells = realloc(t->cells, (t->ncells + pgc->nr_of_cells) * sizeof(cell_t));
    for (int c = 0; c < pgc->nr_of_cells; c++) {
        t->cells[t->ncells].first = pgc->cell_playback[c].first_sector;
        t->cells[t->ncells].last = pgc->cell_playback[c].last_sector;
        t->ncells++;
    }
}

/* DVDs store 639-1 codes; HandBrake presets and MKV use 639-2 */
static void lang3(uint16_t code, int type, char out[4])
{
    static const char *map =
        "aa:aar ar:ara bg:bul ca:cat cs:ces cy:cym da:dan de:deu el:ell en:eng "
        "es:spa et:est eu:eus fa:fas fi:fin fr:fra ga:gle gd:gla he:heb hi:hin "
        "hr:hrv hu:hun id:ind in:ind is:isl it:ita iw:heb ja:jpn ji:yid ko:kor la:lat lt:lit "
        "lv:lav ms:msa nl:nld no:nor pl:pol pt:por ro:ron ru:rus sk:slk sl:slv "
        "sr:srp sv:swe th:tha tl:tgl tr:tur uk:ukr vi:vie zh:zho ";
    char two[3] = { code >> 8, code & 0xff, 0 };
    strcpy(out, "und");
    if (type != 1 || two[0] < 'a' || two[0] > 'z' || two[1] < 'a' || two[1] > 'z')
        return;
    char key[4] = { two[0], two[1], ':', 0 };
    const char *m = strstr(map, key);
    if (m && (m == map || m[-1] == ' '))
        memcpy(out, m + 3, 3);
    else
        memcpy(out, two, 3);
}

static void load_streams(title_t *t, ifo_handle_t *v, pgc_t *pgc)
{
    vtsi_mat_t *mat = v->vtsi_mat;
    static const char *std[] = { "NTSC", "PAL", "?", "?" };
    static const char *aspect[] = { "4:3", "?", "?", "16:9" };
    static char vbuf[MAX_TITLES + 1][16];
    snprintf(vbuf[t - titles], 16, "%s %s", std[mat->vts_video_attr.video_format],
             aspect[mat->vts_video_attr.display_aspect_ratio]);
    t->video = vbuf[t - titles];

    static const char *acodec[] = { "AC3", "?", "MPEG1", "MPEG2", "LPCM", "?", "DTS", "?" };
    static const char *anote[] = { NULL, NULL, "audio description", "commentary", "commentary" };
    int seen = 0;
    for (int i = 0; i < mat->nr_of_vts_audio_streams && i < 8 && t->naudio < MAX_AUDIO; i++) {
        if (!(pgc->audio_control[i] & 0x8000))
            continue;
        int pos = (pgc->audio_control[i] >> 8) & 7;
        if (seen & (1 << pos))
            continue;
        seen |= 1 << pos;
        audio_attr_t *a = &mat->vts_audio_attr[i];
        stream_t *st = &t->audio[t->naudio++];
        lang3(a->lang_code, a->lang_type, st->lang);
        st->codec = acodec[a->audio_format & 7];
        st->channels = a->channels + 1;
        st->note = a->code_extension < 5 ? anote[a->code_extension] : NULL;
    }
    static const char *snote[16] = { [5] = "captions", [6] = "captions", [7] = "captions",
        [9] = "forced", [13] = "commentary", [14] = "commentary", [15] = "commentary" };
    uint32_t sseen = 0;
    for (int i = 0; i < mat->nr_of_vts_subp_streams && i < 32 && t->nsubs < MAX_SUBS; i++) {
        uint32_t c = pgc->subp_control[i];
        if (!(c >> 31))
            continue;
        /* HandBrake keys subtitles on the 4:3 (or wide) stream id */
        int pos = mat->vts_video_attr.display_aspect_ratio == 0 ? (c >> 24) & 0x1f : (c >> 16) & 0x1f;
        if (sseen & (1u << pos))
            continue;
        sseen |= 1u << pos;
        subp_attr_t *sp = &mat->vts_subp_attr[i];
        stream_t *st = &t->subs[t->nsubs++];
        lang3(sp->lang_code, sp->type, st->lang);
        st->note = snote[sp->code_extension & 15];
    }
}

static int load_titles(const char *path)
{
    dvd_logger_cb cb = { quiet_log };
    dvd_reader_t *dvd = DVDOpen2(NULL, &cb, path);
    if (!dvd)
        return -1;
    ifo_handle_t *vmg = ifoOpen(dvd, 0);
    if (!vmg) {
        DVDClose(dvd);
        return -1;
    }
    ntitles = vmg->tt_srpt->nr_of_srpts;
    if (ntitles > MAX_TITLES)
        ntitles = MAX_TITLES;

    ifo_handle_t *vtsifo[100] = { 0 };
    for (int i = 1; i <= ntitles; i++) {
        title_info_t *ti = &vmg->tt_srpt->title[i - 1];
        title_t *t = &titles[i];
        t->vts = ti->title_set_nr;
        t->ttn = ti->vts_ttn;
        t->chapters = ti->nr_of_ptts;
        if (t->vts < 1 || t->vts > 99)
            continue;
        if (!vtsifo[t->vts])
            vtsifo[t->vts] = ifoOpen(dvd, t->vts);
        ifo_handle_t *v = vtsifo[t->vts];
        if (!v || !v->vts_ptt_srpt || t->ttn < 1 || t->ttn > v->vts_ptt_srpt->nr_of_srpts)
            continue;
        ttu_t *ttu = &v->vts_ptt_srpt->title[t->ttn - 1];
        int last_pgcn = -1;
        if (ttu->nr_of_ptts && ttu->ptt[0].pgcn >= 1 && ttu->ptt[0].pgcn <= v->vts_pgcit->nr_of_pgci_srp
            && v->vts_pgcit->pgci_srp[ttu->ptt[0].pgcn - 1].pgc)
            load_streams(t, v, v->vts_pgcit->pgci_srp[ttu->ptt[0].pgcn - 1].pgc);
        for (int p = 0; p < ttu->nr_of_ptts; p++) {
            int pgcn = ttu->ptt[p].pgcn;
            if (pgcn == last_pgcn || pgcn < 1 || pgcn > v->vts_pgcit->nr_of_pgci_srp)
                continue;
            last_pgcn = pgcn;
            add_pgc_cells(t, v->vts_pgcit->pgci_srp[pgcn - 1].pgc);
        }
    }

    /* containment: title B lies inside title A if >= 90% of B's sectors are
     * covered by A's cells (play-alls often split cells differently) */
    for (int a = 1; a <= ntitles; a++) {
        title_t *A = &titles[a];
        uint64_t atot = 0;
        for (int k = 0; k < A->ncells; k++)
            atot += A->cells[k].last - A->cells[k].first + 1;
        int pos[MAX_TITLES + 1];
        for (int b = 1; b <= ntitles; b++) {
            title_t *B = &titles[b];
            if (a == b || A->vts != B->vts || !B->ncells)
                continue;
            uint64_t btot = 0, cov = 0;
            int first = -1;
            for (int j = 0; j < B->ncells; j++) {
                btot += B->cells[j].last - B->cells[j].first + 1;
                for (int k = 0; k < A->ncells; k++) {
                    uint32_t lo = A->cells[k].first > B->cells[j].first ? A->cells[k].first : B->cells[j].first;
                    uint32_t hi = A->cells[k].last < B->cells[j].last ? A->cells[k].last : B->cells[j].last;
                    if (hi >= lo) {
                        cov += hi - lo + 1;
                        if (first < 0 || k < first)
                            first = k;
                    }
                }
            }
            if (atot * 10 < btot * 12 || cov * 10 < btot * 9)
                continue;
            pos[b] = first;
            int n = A->ncontained, ins = n;
            for (int x = 0; x < n; x++)
                if (first < pos[A->container_of[x]]) {
                    ins = x;
                    break;
                }
            memmove(&A->container_of[ins + 1], &A->container_of[ins], (n - ins) * sizeof(int));
            A->container_of[ins] = b;
            A->ncontained++;
            if (!B->contained_in || titles[B->contained_in].ncells > A->ncells)
                B->contained_in = a;
        }
    }

    for (int i = 1; i < 100; i++)
        if (vtsifo[i])
            ifoClose(vtsifo[i]);
    ifoClose(vmg);
    DVDClose(dvd);
    return 0;
}

/* ---------- menu walk (libdvdnav) ---------- */

typedef enum { R_MENU, R_TITLE, R_STOP, R_LIMIT, R_STUCK, R_ERR } rkind_t;

typedef struct {
    rkind_t kind;
    int title, part;       /* R_TITLE */
    int via_title;         /* short title played on the way */
    int menu;              /* R_MENU: index into menus[] */
} result_t;

typedef struct {
    uint64_t id;
    dvdnav_t *nav;         /* state parked at this menu's button NAV packet */
    int vmgm, menu_id, pgcn, nbuttons;
    char name;
} menu_t;

static menu_t menus[MAX_MENUS];

#define MAX_PRESSES 2048
typedef struct { int menu, button, x, y; result_t r; } press_t;
static press_t presses[MAX_PRESSES];
static int npresses;
static result_t first_play;
static int nmenus;
static dvdnav_t *root;

static uint64_t fnv(uint64_t h, const void *p, size_t n)
{
    const uint8_t *b = p;
    while (n--)
        h = (h ^ *b++) * 0x100000001b3ULL;
    return h;
}

/* identify the menu we're parked on; add it if new. Returns index. */
static int menu_here(dvdnav_t *nav, int *is_new)
{
    pci_t *pci = dvdnav_get_current_nav_pci(nav);
    int32_t t = 0, part = 0, pgcn = 0, pgn = 0;
    dvdnav_current_title_info(nav, &t, &part);
    dvdnav_current_title_program(nav, &t, &pgcn, &pgn);
    int vmgm = dvdnav_is_domain_vmgm(nav);
    int nb = pci->hli.hl_gi.btn_ns;

    uint64_t h = 0xcbf29ce484222325ULL;
    h = fnv(h, &vmgm, sizeof vmgm);
    h = fnv(h, &part, sizeof part);
    h = fnv(h, &pgcn, sizeof pgcn);
    for (int i = 0; i < nb && i < 36; i++) {
        btni_t *b = &pci->hli.btnit[i];
        h = fnv(h, b->cmd.bytes, 8);
        uint16_t xy[2] = { b->x_start, b->y_start };
        h = fnv(h, xy, sizeof xy);
    }
    for (int i = 0; i < nmenus; i++)
        if (menus[i].id == h) {
            *is_new = 0;
            return i;
        }
    if (nmenus == MAX_MENUS)
        return -1;
    menu_t *m = &menus[nmenus];
    m->id = h;
    m->nav = NULL;
    m->vmgm = vmgm;
    m->menu_id = part;
    m->pgcn = pgcn;
    m->nbuttons = nb;
    m->name = nmenus < 26 ? 'A' + nmenus : 'a' + nmenus - 26;
    *is_new = 1;
    return nmenus++;
}

static double title_secs(int t)
{
    return (t >= 1 && t <= ntitles) ? titles[t].secs : 0;
}

/* run the VM until it parks on a menu with buttons or starts a real title.
 * origin is the menu a button was pressed on (-1 if none): many discs only
 * set a register on the press and jump when the menu's cell or still ends,
 * so the origin menu only counts once playback has looped back to the
 * program it was parked on. */
static result_t run(dvdnav_t *nav, int origin)
{
    uint8_t buf[DVD_VIDEO_LB_LEN];
    result_t r = { .kind = R_ERR };
    int via = 0, looped = 0, moved = 0;
    int32_t ot = 0, opgcn = 0, opgn = 0;
    if (origin >= 0)
        dvdnav_current_title_program(nav, &ot, &opgcn, &opgn);
    for (long n = 0; n < BLOCK_LIMIT; n++) {
        int32_t ev, len;
        if (dvdnav_get_next_block(nav, buf, &ev, &len) != DVDNAV_STATUS_OK) {
            if (verbose)
                fprintf(stderr, "dvdnav: %s\n", dvdnav_err_to_string(nav));
            return r;
        }
        switch (ev) {
        case DVDNAV_STILL_FRAME:
            dvdnav_still_skip(nav);
            break;
        case DVDNAV_WAIT:
            dvdnav_wait_skip(nav);
            break;
        case DVDNAV_STOP:
            r.kind = R_STOP;
            r.via_title = via;
            return r;
        case DVDNAV_CELL_CHANGE:
            if (dvdnav_is_domain_vts(nav)) {
                int32_t t = 0, p = 0;
                dvdnav_current_title_info(nav, &t, &p);
                if (t > 0 && title_secs(t) >= SHORT_TITLE_SECS) {
                    r.kind = R_TITLE;
                    r.title = t;
                    r.part = p;
                    r.via_title = via;
                    return r;
                }
                if (t > 0)
                    via = t;
            } else {
                int32_t t = 0, pgcn = 0, pgn = 0;
                dvdnav_current_title_program(nav, &t, &pgcn, &pgn);
                if (pgcn != opgcn || pgn != opgn)
                    moved = 1;
                else if (moved)
                    looped = 1;
            }
            break;
        case DVDNAV_NAV_PACKET:
            if (dvdnav_is_domain_vmgm(nav) || dvdnav_is_domain_vtsm(nav)) {
                pci_t *pci = dvdnav_get_current_nav_pci(nav);
                if (pci->hli.hl_gi.hli_ss && pci->hli.hl_gi.btn_ns > 0) {
                    int is_new;
                    r.menu = menu_here(nav, &is_new);
                    if (r.menu == origin && !looped)
                        break;
                    r.kind = R_MENU;
                    r.via_title = via;
                    if (r.menu >= 0 && is_new)
                        menus[r.menu].nav = nav;   /* caller decides ownership */
                    return r;
                }
            }
            break;
        }
    }
    r.kind = R_LIMIT;
    r.via_title = via;
    return r;
}

/* Some discs' navigation commands loop forever inside libdvdnav, which then
 * never hands back a block for run() to count. A CPU-time timer jumps out
 * of such a press; that VM is abandoned (its lock may be held), not freed. */
static sigjmp_buf stuck_jmp;
static volatile sig_atomic_t armed;
static result_t guarded;

static void on_stuck(int sig)
{
    (void)sig;
    if (armed)
        siglongjmp(stuck_jmp, 1);
}

static void set_timer(int secs)
{
    struct itimerval it = { .it_value = { .tv_sec = secs } };
    setitimer(ITIMER_PROF, &it, NULL);
}

/* press button (if > 0) on nav, then run(); R_STUCK if it never comes back */
static result_t run_guarded(dvdnav_t *nav, int button, int origin)
{
    if (sigsetjmp(stuck_jmp, 1)) {
        guarded = (result_t){ .kind = R_STUCK };
    } else {
        armed = 1;
        set_timer(STUCK_CPU_SECS);
        if (button > 0)
            dvdnav_button_select_and_activate(nav, dvdnav_get_current_nav_pci(nav), button);
        guarded = run(nav, origin);
    }
    armed = 0;
    set_timer(0);
    return guarded;
}

static void fmt_time(double s, char *out)
{
    int x = (int)(s + 0.5);
    sprintf(out, "%d:%02d:%02d", x / 3600, x / 60 % 60, x % 60);
}

static void print_result(result_t r, int from_menu)
{
    char tb[16];
    if (r.via_title && !(r.kind == R_TITLE && r.via_title == r.title))
        printf("(plays title %d first) ", r.via_title);
    switch (r.kind) {
    case R_TITLE:
        fmt_time(title_secs(r.title), tb);
        printf("title %2d, chapter %d   [%s]", r.title, r.part, tb);
        if (titles[r.title].ncontained)
            printf("  PLAY-ALL");
        break;
    case R_MENU:
        if (r.menu < 0)
            printf("menu (too many menus)");
        else if (r.menu == from_menu)
            printf("same menu (setting/no-op)");
        else
            printf("menu %c", menus[r.menu].name);
        break;
    case R_STOP:
        printf("stop");
        break;
    case R_LIMIT:
        printf("? (gave up after %d blocks)", BLOCK_LIMIT);
        break;
    case R_STUCK:
        printf("? (disc commands loop forever; gave up)");
        break;
    default:
        printf("? (navigation error)");
    }
    putchar('\n');
}

static void usage(FILE *f)
{
    fputs("usage: dvdmap [OPTIONS] DVD_DIR_OR_DEVICE\n"
          "Walk a DVD's menus to find which titles are episodes, and in what order.\n"
          "\n"
          "  -m, --menus      also print the full menu walk (every button on every menu)\n"
          "  -s, --streams    also list each title's audio and subtitle tracks\n"
          "  -c, --chapters   also list the length of every chapter of every title\n"
          "  -p, --porcelain  machine-readable output (used by dvd2mkv)\n"
          "  -v, --verbose    show libdvdread/libdvdnav log messages\n"
          "  -h, --help       show this help\n", f);
}

/* porcelain notes go out as one word: "audio description" -> "audio-description" */
static void put_note(const char *note)
{
    if (!note)
        return;
    putchar(':');
    for (; *note; note++)
        putchar(*note == ' ' ? '-' : *note);
}

/* porcelain: n:lang:codec:channels[:note],...   human: n lang codec 5.1 (note), ... */
static void print_streams(const title_t *t, int human)
{
    if (!t->naudio)
        printf(human ? "none" : "-");
    for (int i = 0; i < t->naudio; i++) {
        const stream_t *a = &t->audio[i];
        if (!human) {
            printf("%s%d:%s:%s:%d", i ? "," : "", i + 1, a->lang, a->codec, a->channels);
            put_note(a->note);
            continue;
        }
        printf("%s%d %s %s ", i ? ", " : "", i + 1, a->lang, a->codec);
        if (a->channels == 6)
            printf("5.1");
        else
            printf("%d.0", a->channels);
        if (a->note)
            printf(" (%s)", a->note);
    }
}

static void print_subs(const title_t *t, int human)
{
    if (!t->nsubs)
        printf(human ? "none" : "-");
    for (int i = 0; i < t->nsubs; i++) {
        const stream_t *sp = &t->subs[i];
        if (!human) {
            printf("%s%d:%s", i ? "," : "", i + 1, sp->lang);
            put_note(sp->note);
        } else {
            printf("%s%d %s", i ? ", " : "", i + 1, sp->lang);
            if (sp->note)
                printf(" (%s)", sp->note);
        }
    }
}

static void print_title_table(const int *reached, const int *parts_seen, int found_menu)
{
    printf("\nTitles (HandBrake -t numbers):\n");
    printf("  title  length    ch  vts  on menus  notes\n");
    for (int t = 1; t <= ntitles; t++) {
        char tb[16];
        fmt_time(titles[t].secs, tb);
        printf("  %5d  %s  %3d  %3d  ", t, tb, titles[t].chapters, titles[t].vts);
        if (reached[t] > 0)
            printf("%3d btn%s  ", reached[t], reached[t] == 1 ? " " : "s");
        else if (reached[t] < 0)
            printf("1st play  ");
        else
            printf("    -     ");
        if (titles[t].ncontained) {
            printf("play-all of titles");
            for (int i = 0; i < titles[t].ncontained; i++)
                printf(" %d", titles[t].container_of[i]);
        } else if (titles[t].contained_in) {
            printf("in play-all %d", titles[t].contained_in);
        } else if (titles[t].secs < SHORT_TITLE_SECS) {
            printf("short (warning/transition/dummy)");
        }
        if (parts_seen[t] > 1)
            printf("%smenus jump to %d different chapters",
                   titles[t].ncontained || titles[t].contained_in ? "; " : "", parts_seen[t]);
        putchar('\n');
    }
    if (!found_menu)
        printf("\n(could not reach a menu; on-menu column is empty)\n");
    if (show_streams) {
        printf("\nTracks (numbered as HandBrake's --audio / --subtitle count them):\n");
        for (int t = 1; t <= ntitles; t++) {
            printf("  %5d  %-9s  audio: ", t, titles[t].video ? titles[t].video : "?");
            print_streams(&titles[t], 1);
            printf("\n                    subs:  ");
            print_subs(&titles[t], 1);
            putchar('\n');
        }
    }
}

int main(int argc, char **argv)
{
    static const struct option longopts[] = {
        { "menus", no_argument, NULL, 'm' },
        { "streams", no_argument, NULL, 's' },
        { "chapters", no_argument, NULL, 'c' },
        { "porcelain", no_argument, NULL, 'p' },
        { "verbose", no_argument, NULL, 'v' },
        { "help", no_argument, NULL, 'h' },
        { 0 }
    };
    for (int c; (c = getopt_long(argc, argv, "mscpvh", longopts, NULL)) != -1;) {
        switch (c) {
        case 'm': show_menus = 1; break;
        case 's': show_streams = 1; break;
        case 'c': show_chapters = 1; break;
        case 'p': porcelain = 1; break;
        case 'v': verbose = 1; break;
        case 'h': usage(stdout); return 0;
        default: usage(stderr); return 2;
        }
    }
    if (optind != argc - 1) {
        usage(stderr);
        return 2;
    }
    const char *path = argv[optind];

    if (load_titles(path) < 0) {
        fprintf(stderr, "dvdmap: cannot read DVD structure at %s\n", path);
        return 1;
    }

    dvdnav_logger_cb ncb = { quiet_navlog };
    if (dvdnav_open2(&root, NULL, &ncb, path) != DVDNAV_STATUS_OK) {
        fprintf(stderr, "dvdmap: dvdnav cannot open %s\n", path);
        return 1;
    }
    dvdnav_set_readahead_flag(root, 0);
    dvdnav_set_PGC_positioning_flag(root, 1);

    for (int t = 1; t <= ntitles; t++) {
        uint64_t *times = NULL, dur = 0;
        uint32_t n = dvdnav_describe_title_chapters(root, t, &times, &dur);
        titles[t].secs = dur / 90000.0;
        /* times[] holds each chapter's end time; keep the lengths */
        if (times && n > 0) {
            titles[t].chsecs = calloc(n, sizeof(double));
            titles[t].nchsecs = (int)n;
            uint64_t prev = 0;
            for (uint32_t c = 0; c < n; c++) {
                titles[t].chsecs[c] = (times[c] > prev ? times[c] - prev : 0) / 90000.0;
                prev = times[c];
            }
        }
        free(times);
    }

    const char *volid = dvdnav_get_volid_string(root);
    if (!porcelain)
        printf("Disc: %s   (volume id: %s)\n", path, volid ? volid : "?");

    struct sigaction sa = { .sa_handler = on_stuck };
    sigaction(SIGPROF, &sa, NULL);

    /* first play, then make sure we end up on a menu */
    result_t r = run_guarded(root, 0, -1);
    first_play = r;
    if (r.kind == R_TITLE || r.kind == R_STOP || r.kind == R_LIMIT) {
        static const DVDMenuID_t tries[] = { DVD_MENU_Root, DVD_MENU_Title };
        for (size_t i = 0; i < 2 && r.kind != R_MENU; i++)
            if (dvdnav_menu_call(root, tries[i]) == DVDNAV_STATUS_OK)
                r = run_guarded(root, 0, -1);
    }
    if (r.kind == R_MENU && r.menu >= 0) {
        /* breadth-first over menus; each button pressed on a duplicate VM */
        for (int mi = 0; mi < nmenus; mi++) {
            menu_t *m = &menus[mi];
            if (!m->nav)
                continue;
            pci_t *pci = dvdnav_get_current_nav_pci(m->nav);
            for (int b = 1; b <= m->nbuttons && npresses < MAX_PRESSES; b++) {
                press_t *p = &presses[npresses++];
                p->menu = mi;
                p->button = b;
                p->x = pci->hli.btnit[b - 1].x_start;
                p->y = pci->hli.btnit[b - 1].y_start;
                dvdnav_t *d = NULL;
                if (dvdnav_dup(&d, m->nav) != DVDNAV_STATUS_OK) {
                    p->r.kind = R_ERR;
                    continue;
                }
                p->r = run_guarded(d, b, mi);
                if (p->r.kind != R_STUCK
                    && !(p->r.kind == R_MENU && p->r.menu >= 0 && menus[p->r.menu].nav == d))
                    dvdnav_free_dup(d);   /* not parked as a new menu */
            }
        }
    }

    /* which titles does the menu system actually lead to? */
    int reached[MAX_TITLES + 1] = { 0 };          /* number of buttons */
    int parts_seen[MAX_TITLES + 1] = { 0 };       /* distinct chapters targeted */
    uint64_t partmask[MAX_TITLES + 1] = { 0 };
    for (int i = 0; i < npresses; i++) {
        result_t *pr = &presses[i].r;
        if (pr->kind != R_TITLE)
            continue;
        int t = pr->title;
        reached[t]++;
        if (pr->part >= 1 && pr->part <= 64 && !(partmask[t] & (1ULL << (pr->part - 1)))) {
            partmask[t] |= 1ULL << (pr->part - 1);
            parts_seen[t]++;
        }
    }
    if (first_play.kind == R_TITLE && !reached[first_play.title])
        reached[first_play.title] = -1;              /* only via first play */

    if (!porcelain)
        print_title_table(reached, parts_seen, r.kind == R_MENU);

    /* episode guess: the longest group of non-play-all titles reachable from
     * a menu (anything at least half as long as the longest one) */
    int cand[MAX_TITLES], nc = 0, is_cand[MAX_TITLES + 1] = { 0 };
    double longest = 0;
    for (int t = 1; t <= ntitles; t++)
        if (reached[t] > 0 && !titles[t].ncontained && titles[t].secs >= SHORT_TITLE_SECS
            && titles[t].secs > longest)
            longest = titles[t].secs;
    for (int t = 1; t <= ntitles; t++)
        if (reached[t] > 0 && !titles[t].ncontained && titles[t].secs >= SHORT_TITLE_SECS
            && titles[t].secs >= longest / 2)
            is_cand[cand[nc++] = t] = 1;

    /* order 1: button order on the menu reaching the most candidates */
    int menu_hits[MAX_MENUS] = { 0 }, best = -1;
    for (int i = 0; i < npresses; i++)
        if (presses[i].r.kind == R_TITLE && is_cand[presses[i].r.title])
            menu_hits[presses[i].menu]++;
    for (int m = 0; m < nmenus; m++)
        if (menu_hits[m] && (best < 0 || menu_hits[m] > menu_hits[best]))
            best = m;
    int order[MAX_TITLES], no = 0, used[MAX_TITLES + 1] = { 0 };
    if (best >= 0)
        for (int i = 0; i < npresses; i++)
            if (presses[i].menu == best && presses[i].r.kind == R_TITLE
                && is_cand[presses[i].r.title] && !used[presses[i].r.title])
                used[order[no++] = presses[i].r.title] = 1;
    for (int i = 0; i < nc; i++)           /* candidates on other menus */
        if (!used[cand[i]])
            used[order[no++] = cand[i]] = 1;

    /* order 2: a play-all that holds every candidate */
    int pa_order[MAX_TITLES], npa = 0;
    for (int t = 1; t <= ntitles && !npa; t++) {
        int n = 0;
        for (int i = 0; i < titles[t].ncontained; i++)
            if (is_cand[titles[t].container_of[i]])
                pa_order[n++] = titles[t].container_of[i];
        if (nc > 1 && n == nc)
            npa = n;
    }
    int agree = npa && !memcmp(pa_order, order, nc * sizeof(int));
    const char *how = best < 0 ? "none found"
                    : npa && agree ? "menu button order; play-all agrees"
                    : npa ? "PLAY-ALL ORDER DIFFERS from menu order, check!"
                    : "menu button order";
    int extra[MAX_TITLES], ne = 0;
    for (int t = 1; t <= ntitles; t++)
        if (reached[t] > 0 && !titles[t].ncontained && titles[t].secs >= SHORT_TITLE_SECS && !is_cand[t])
            extra[ne++] = t;

    /* flag lengths far from the median as probable extras */
    double lens[MAX_TITLES];
    for (int i = 0; i < no; i++)
        lens[i] = titles[order[i]].secs;
    double median = 0;
    if (no) {
        for (int i = 1; i < no; i++)
            for (int j = i; j > 0 && lens[j - 1] > lens[j]; j--) {
                double tmp = lens[j]; lens[j] = lens[j - 1]; lens[j - 1] = tmp;
            }
        median = lens[no / 2];
    }

    if (porcelain) {
        /* one record per line: "volid V", "episodes T...", "order HOW", then
         * "title N secs S chapters C video STD/ASPECT audio LIST subs LIST"
         * where LIST is comma-separated n:lang:codec:channels[:note] (audio)
         * or n:lang[:note] (subs), or "-" */
        printf("volid %s\nepisodes", volid && *volid ? volid : "?");
        for (int i = 0; i < no; i++)
            printf(" %d", order[i]);
        printf("\norder %s\n", how);
        for (int t = 1; t <= ntitles; t++) {
            char v[16];
            snprintf(v, sizeof v, "%s", titles[t].video ? titles[t].video : "?");
            for (char *c = v; *c; c++)
                if (*c == ' ')
                    *c = '/';
            printf("title %d secs %.0f chapters %d video %s audio ", t, titles[t].secs,
                   titles[t].chapters, v);
            print_streams(&titles[t], 0);
            printf(" subs ");
            print_subs(&titles[t], 0);
            putchar('\n');
            /* "chapters N S1 S2 ..." : each chapter's length in seconds */
            if (titles[t].nchsecs) {
                printf("chapters %d", t);
                for (int c = 0; c < titles[t].nchsecs; c++)
                    printf(" %.0f", titles[t].chsecs[c]);
                putchar('\n');
            }
        }
        return 0;
    }

    printf("\nLikely episodes (%s):\n", how);
    for (int i = 0; i < no; i++) {
        char tb[16];
        int t = order[i];
        fmt_time(titles[t].secs, tb);
        printf("  %2d. title %2d  [%s]", i + 1, t, tb);
        if (median && (titles[t].secs < 0.6 * median || titles[t].secs > 1.5 * median))
            printf("  <- length unlike the others; bonus?");
        if (!reached[t])
            printf("  <- not on any menu");
        putchar('\n');
    }
    if (ne) {
        printf("Other titles reachable from the menus (extras?):");
        for (int i = 0; i < ne; i++) {
            char tb[16];
            fmt_time(titles[extra[i]].secs, tb);
            printf(" %d [%s]", extra[i], tb);
        }
        putchar('\n');
    }
    if (npa && !agree) {
        printf("  play-all order:");
        for (int i = 0; i < npa; i++)
            printf(" %d", pa_order[i]);
        putchar('\n');
    }
    if (no) {
        printf("\nHandBrake title list for this disc:");
        for (int i = 0; i < no; i++)
            printf(" %d", order[i]);
        putchar('\n');
    }

    if (show_chapters) {
        printf("\nChapter lengths (for dvd2mkv --chapters on discs where episodes are chapters):\n");
        for (int t = 1; t <= ntitles; t++) {
            if (titles[t].secs < SHORT_TITLE_SECS || !titles[t].nchsecs)
                continue;
            printf("  title %2d:", t);
            for (int c = 0; c < titles[t].nchsecs; c++) {
                char tb[16];
                fmt_time(titles[t].chsecs[c], tb);
                printf("%s%d=%s", c % 6 == 0 && c ? "\n           " : " ", c + 1, tb);
            }
            putchar('\n');
        }
    }

    if (show_menus) {
        printf("\nMenu walk:\n  first play -> ");
        print_result(first_play, -1);
        int last = -1;
        for (int i = 0; i < npresses; i++) {
            press_t *p = &presses[i];
            if (p->menu != last) {
                menu_t *m = &menus[p->menu];
                printf("\n  menu %c  (%s menu, pgc %d, %d buttons)\n", m->name,
                       m->vmgm ? "disc" : "title-set", m->pgcn, m->nbuttons);
                last = p->menu;
            }
            printf("    button %2d  @(%3d,%3d)  -> ", p->button, p->x, p->y);
            print_result(p->r, p->menu);
        }
    }
    return 0;
}
