Movie Metadata editor

Movie metadata editor for Windows, to display and edit metadata of MP4 and MKV video files. It uses external applications to read and write metadata to/from movie files. On startup the script looks for three external applications: AtomicParsley.exe, mkvpropedit.exe, mkvinfo.exe

For MP4 video: Atomic Parsley, from sourceforge For MKV video: mkvinfo (to read) and mkvpropedit (to write), from MKVToolNix

It has issues with writing metadata containing the symbols & and " atomicparsley refuses to modify tags on large files over 2.9 GB with message "doesn't have full 64-bit support"

Not my prettiest code but it functions well enough for my uses so I thought to share it.

# workaround for images that wont display with error: Failed to create temporary file
set env(JPEGMEM) 1000M

foreach t {Img} {
        if {[catch {package require $t} result]} {
                puts "- $t package failed to load: $result"
        } else {
                puts "+ $t package loaded"
        }
}
wm minsize . 150 150
wm title . "Movie Metadata Editor - by Frank Bannon (2024)"
catch {wm iconbitmap . -default Metadata.ico}
set dir [file dirname [info script]]
array set var {}
set var(MP4_EDIT) [file join $dir AtomicParsley.exe]
set var(MKV_EDIT) [file join $dir mkvpropedit.exe]
set var(MKV_INFO) [file join $dir mkvinfo.exe]
set var(overwrite) 1
set var(gumball_delay) 2000
set var(display_thumb) 1

# check external apps exist
foreach {lbl exe vrb} {
        AtomicParsley AtomicParsley.exe MP4_EDIT
        MKVInfo mkvinfo.exe MKV_INFO
        MKVPropEdit mkvpropedit MKV_EDIT        
} {
if {![file exists $var($vrb)]} {
        set filetypes {
        {{Executable Files} {.exe} }
        {{All Files} *}
        }
        set f [tk_getOpenFile -title "Find $lbl" -filetypes $filetypes -initialfile $exe]
        if {[string length $f] && [file exists $f]} {set var($vrb) $f}
}
}
# toggles to lock metadata values to apply to later files
array set lock {}
# metadata read from file or entered in gui to save back to file
array set data {}
# tooltip balloon help
proc tooltip {w h} {
        global val
        bind $w <Any-Enter> "after 500 [list tooltip:show %W [list $h]]"
        bind $w <Any-Leave> "destroy %W.tooltip"
}
proc tooltip:show {w arg} {
        if {[eval winfo containing  [winfo pointerxy .]]!=$w} {return}
        set top $w.tooltip
        catch {destroy $top}
        toplevel $top -bd 1 -bg gray
        wm overrideredirect $top 1
        set font TkDefaultFont
        if {$::tcl_platform(platform) == "unix"} {set font fixed}
        pack [message $top.txt -aspect 10000 -bg lightyellow -font $font -width 450 -text $arg]
        set wmx [winfo rootx $w]
        set wmy [expr {[winfo rooty $w] + [winfo height $w]}]
        wm geometry $top [winfo reqwidth $top.txt]x[winfo reqheight $top.txt]+$wmx+$wmy
        raise $top
}

# create GUI
set f .f1
frame $f
pack $f -side top -anchor nw
button $f.choosedir -text "Select directory" -command choose_dir
        tooltip $f.choosedir "Choose a folder of files to tag"
button $f.refresh -text "Refresh" -command {
        # separate mp4 and mkv
        set filelist [list [lsort -dict [glob -types f -nocomplain *.mp4]] [lsort -dict [glob -types f -nocomplain *.mkv]]]
        set filelist [join $filelist]
#        set filelist [lsort -dict [glob -types f -nocomplain *.mp4 *.mkv]]
}
        tooltip $f.refresh "Refresh file list"
# intended to apply all LOCKED fields to all the files in the current folder
button $f.writeall -text "Tag All Files" -command {
        set stop 0
        for {set n 0} {$n <= [.f2.f1.list size]} {incr n} {
                .f2.f1.list selection clear 0 end
                .f2.f1.list selection set $n
                if {$stop} {break}
                log clear
                log "----"
                log "reading file list item $n"
                read_file
                log "writing $n"
                write_file
        }
}
        tooltip $f.writeall "Write tags to all files in list\nUseful when some fields are Locked\nLike Movie, Genre, Comments"
button $f.stop -text "Stop" -command {set stop 1}
        tooltip $f.stop "Stop tagging all files"
label $f.pad1 -text { -> }
label $f.pad2 -text { <- }
# write all metadata back to file, overwriting
button $f.write -text "Write tags" -command write_file -borderwidth 4
        tooltip $f.write "Write tag fields to file"
# remove all metadata from file, used when tool throws error on read, to allow edits to succeed
button $f.clear -text "Clear tags" -command {write_file clear}
        tooltip $f.clear "Remove all metadata tags from file"
# read file and send to console all discovered metadata
button $f.alltags -text "File Metadata" -command full_info
        tooltip $f.alltags "Show all metadata of file in Console"
grid $f.choosedir $f.refresh $f.pad1 $f.write $f.pad2 $f.clear $f.alltags $f.writeall $f.stop -padx 3

set f .f2
frame $f
pack $f -side top -anchor nw -expand 1 -fill both

# filename listing
set f .f2.f1
frame $f
pack $f -side left -anchor nw -fill both -expand 1
scrollbar $f.x -orient horizontal -command "$f.list xview"
scrollbar $f.y -orient vertical -command "$f.list yview"
listbox $f.list -listvariable filelist -xscrollcommand "$f.x set" -yscrollcommand "$f.y set" -width 40
grid $f.list $f.y -sticky nsew
grid $f.x -sticky nsew
grid rowconfigure $f $f.list -weight 1
grid columnconfigure $f $f.list -weight 1
bind $f.list <<ListboxSelect>> read_file

# column container to right of listbox
set f .f2.f2
frame $f
pack $f -side top -anchor nw
label $f.filename -textvariable data(filename) -font {-size 12}
pack $f.filename -side top
set f .f2.f3
frame $f
pack $f -side top -anchor nw -fill x
# file type tags supported by MP4 only, not MKV
foreach {num txt} {0 Movie 1 Normal 2 Audiobook 6 "Music Video" 9 "Short Film" 10 "TV Show"} {
        radiobutton $f.type$num -text $txt -variable data(stik) -value $txt \
                -indicatoron 0 -relief raised -bd 1 -highlightthickness 0 -anchor c \
                -selectcolor green3
        pack $f.type$num -side left -padx 3
}
checkbutton $f.lock -text "L" -variable lock(stik) \
                -indicatoron 0 -relief raised -bd 1 -highlightthickness 0 -anchor c \
                -selectcolor green3 -command "set_lockdata stik"
        tooltip $f.lock "Lock this value"
        pack $f.lock -side left
# metadata table
set f .f2.f4
frame $f
pack $f -side top -anchor nw
set count 1
foreach {lbl tag} {
Title @nam
Artist @ART
Album @alb
Grouping @grp
Genre @gen
Year @day
Track track
Disc disc
Comments @cmt
Encoder @too
TVNetwork tvnn
TVShowName tvsh
TVEpisode tven
TVSeasonNum tvsn
TVEpisodeNum tves
} {
        label $f.lbl$count -text $lbl
        entry $f.ent$count -textvariable data($tag) -width 40
        checkbutton $f.lock$count -text "L" -variable lock($tag) \
                -indicatoron 0 -relief raised -bd 1 -highlightthickness 0 -anchor c \
                -selectcolor green3 -command "set_lockdata $tag"
                tooltip $f.lock$count "Lock this value"
        button $f.clr$count -text "X" -command "set data($tag) {}"
                tooltip $f.clr$count "Clear field"
        if {$tag == "@nam"} {
                # TITLE FIELD
                button $f.copy -text "C" -command filename_to_title
                        tooltip $f.copy "Copy filename to here"
                button $f.rename -text "Ren" -command rename_file
                        tooltip $f.rename "Make filename match Title"
                grid $f.lbl$count $f.ent$count $f.lock$count $f.clr$count $f.copy $f.rename -sticky w
        } else {
                grid $f.lbl$count $f.ent$count $f.lock$count $f.clr$count -sticky w
        }
        incr count
}
# save or clear lock data from table
proc set_lockdata {v} {
        global data lock lockdata
        if {$lock($v)} {
                set lockdata($v) $data($v)
                puts "locked $v: $data($v)"
        } else {
                set lockdata($v) ""
                puts "unlocked $v"
        }
}

# artwork hack
set tag artw
button $f.lbl$count -text Artwork -command {
        set f [tk_getOpenFile -title "Select JPG file" -filetypes {{{JPEG Files} {.jpg}}
        {{PNG Files} {.png} }
        {{All Files} *}} ]
        if {[file exists $f]} {set data(artw) $f}
}
        tooltip $f.lbl$count "Select artwork file to embed"
label $f.ent$count -textvariable data($tag) -width 40
button $f.clr$count -text "X" -command "set data($tag) {}"
        tooltip $f.clr$count "Clear field"
        grid $f.lbl$count $f.ent$count x $f.clr$count

# console
set f .f4
frame $f
scrollbar $f.x -orient horizontal -command [list $f.txt xview]
scrollbar $f.y -orient vertical -command [list $f.txt yview]
text $f.txt -width 80 -height 8 -bg white -wrap word \
        -undo 0 -maxundo 0 -font {Courier 8} \
        -xscrollcommand [list $f.x set] -yscrollcommand [list $f.y set]
pack [ttk::sizegrip $f.grip] -side right -anchor se
pack $f.x -side bottom -fill x
pack $f.y -side right -fill y
pack $f.txt -side left -fill both -expand 1
pack $f -side bottom -fill x -anchor nw

# MAIN LOG ADD ALL KNOWN COLORS AS TAGS
foreach {tag fg bg} {
 debug        black gray70
 ok black white
 warn        black yellow
 err        red white
} {
        $f.txt tag configure $tag -foreground $fg -background $bg
}
proc choose_dir {args} {
        global filelist
        set tmp [tk_chooseDirectory]
        if {[string length $tmp] < 1} {return}
        if {[catch {cd $tmp} result]} {return}
        set filelist [list [lsort -dict [glob -types f -nocomplain *.mp4]] [lsort -dict [glob -types f -nocomplain *.mkv]]]
        set filelist [join $filelist]
}
# write to console and to text widget
proc log {{str ""} {tag ""}} {
        if {$str == "clear"} {.f4.txt delete 1.0 end; return}
        puts $str
        .f4.txt insert end "$str" $tag
        .f4.txt insert end "\n"
}
# update write status gumball
proc status {{str "Write tags"} {fg SystemButtonText} {bg SystemButtonFace}} {
        if {[catch {.f1.write configure -text $str -foreground $fg -background $bg} result]} {
        puts $result}
        update
}
# RETURN NICELY-FORMATTED FILE SIZE STRING WITH KB MB GB TB EB
# BROKEN ABOVE ABOUT 9223372030000000000 (7.9 EB) when number is 34 characters long
proc nice_size {num {decimals 1} {fullname 0}} {
        set remainder 1
        if {$decimals > 0} {set remainder [expr {pow(10,$decimals) + 0.0}]}
        foreach {n short long} {80 YB YottaByte 70 ZB ZettaByte 60 EB ExaByte 50 PB PetaByte 40 TB TeraByte 30 GB GigaByte 20 MB MegaByte 10 KB KiloByte} {
                set amount [expr {pow(2,$n)}]
                if {$fullname} {set long " ($long)"} else {set long ""}
                if {$num >= $amount} {return "[expr {int($num * $remainder / $amount) / $remainder}] $short$long"}
        }
        return $num
}
# RETURN A FORMATTED NUMBER WITH DESIRED DECIMAL POINTS
proc decimals {n d} {
        global tcl_precision
        set decpts [expr {pow(10,$d)}]
        if {$d < 1} {
                set x [string first "." $n]
                # number is float, has decimal point
                if {$x > -1} {
                        incr x -1
                        incr x $d
                        return [string range $n 0 $x]}
        }
        # number is whole, no decimal
        set result [expr {wide($decpts * $n) / $decpts}]
}
# rename filename to match Title field, useful to recover mangled filenames
proc rename_file {args} {
        global data
        if {![file exists $data(filename)]} {log "File not found: $data($filename)" err; return}
        set newname $data(@nam)[file extension $data(filename)]
        if {[catch {file rename $data(filename) $newname} result]} {
                log $result err
        } else {
                log "Renamed $data(filename) to $newname"
                catch {.f1.refresh invoke}
        }
}
# write to console full output from metadata reader on selected file
proc full_info {args} {
        global var data
        if {[info exists data(filename)]} {set filename $data(filename)} else {log "no filename given"; return}
        log clear
        log "See Console for all metadata in $filename"
        set ext [string trimleft [string tolower [file extension $filename]] .]
        switch -- $ext {
        mp4 {catch {exec $var(MP4_EDIT) $filename -t} result}
        mkv {catch {exec $var(MKV_INFO) $filename} result}
        default {set result "unknown file type $ext"}
        }
        puts $result
}
proc display_photo {filename} {
        set w .image
        if {![winfo exists $w.c]} {
                # create window
                toplevel $w -background black
                canvas $w.c -background black -width 600 -height 600
                label $w.t -background black -foreground white -font {* 14}
                pack $w.c -fill both -expand 1
                pack $w.t -fill x
        }
        if {[file readable $filename]} {
                # display file
                catch {image delete pic}
                image create photo pic -file $filename
                $w.c create image 1 1 -image pic -anchor nw -tag image                        
                $w.t configure -text "$filename"
        }
}
proc read_file {args} {
        global var data lock lockdata filelist
        # get filename from list
        set n [.f2.f1.list curselection]
        if {[string length $n] < 1} {return}
        # clear old data first
        foreach tag [array names data] {set data($tag) ""}
        log clear
        set data(filename) [lindex $filelist $n]
        puts "----"
        log $data(filename)
        if {![file exists $data(filename)]} {log "File not found $data(filename)"; return}
        log "File size: [nice_size [file size $data(filename)]]"
        status "Reading tags"
        set ext [string trimleft [string tolower [file extension $data(filename)]] .]
        # delete thumbnail
        catch {image delete pic}
        # use appropriate tool for the file type mp4/mkv
        switch -- $ext {
                mp4 {
                        log "MP4 container"
                        if {[catch {exec $var(MP4_EDIT) $data(filename) -t} result]} {
                                log "error: $result"
                                # disable tag fields
                                # auto clear exisiting tags to allow write of new tags
                                write_file clear
                                return
                        }
                        # enable tag fields
                        # parse tags
                        foreach str [split $result "\n"] {
                                # handle itunes ---- tags
                                regsub -all {"\)} $str {"} str
                                if {[string first Atom $str] > -1} {
                                set atom [lindex $str 1]
                                set cont [string range $str [string first contains: $str]+10 end]
                                # remove junk
                                regsub [binary format c "-62"] $atom "" atom
                                regsub [binary format c "-87"] $atom "@" atom
                                # sub
                                if {$atom == "gnre"} {set atom "@gen"}
                                set data($atom) $cont
                                log "$atom : $cont"
                                }
                        }
                        # attempt artwork extract to file
                        if {![catch {exec $var(MP4_EDIT) $data(filename) --extractPix} result]} {
                                set pix [string trim [lindex [split $result ":"] 1]]
                                if {[info exists var(display_thumb)] && $var(display_thumb)} {
                                        if {[file exists $pix]} {display_photo $pix}
                                }
                        }
                }
                mkv {
                        log "Matroska container"
                        if {[catch {exec $var(MKV_INFO) $data(filename)} result]} {
                                log "error: $result"
                                status "error reading" black red
                                after $var(gumball_delay) status
                        }
                        # tags section, tag data on second line
                        foreach {str tag} {"Name: DATE_RELEASED" @day "+ Title:" @nam "+ Date:" @day "+ Writing application:" @too "Multiplexing application:" @too "Pixel height:" @grp} {
                                set idx1 [string first $str $result]
                                if {$idx1 > -1} {
                                        set idx1 [expr $idx1 + [string length $str] + 1]
                                        set idx2 [string first "\n" $result $idx1]
                                        if {$idx2 < 0} {set idx2 end} else {incr idx2 -1} ;# remove newline
                                        set data($tag) [string range $result $idx1 $idx2]
                                        log "$str: $data($tag)"
                                }
                        }
                        if {[string length $data(@day)] > 3} {
                                # make date format YYYY-mm-ddTHH:MM:SS+zz:zz
                                set data(@day) [clock format [clock scan $data(@day)] -format "%Y-%m-%dT%H:%M:%S+00:00"]
                        }
                }
                default {
                        log "unknown file type $ext"
                }
        }
        # if title is empty, copy filename
        if {[string length $data(@nam)] < 1} {
                log "Title field (@nam) empty." warn
                filename_to_title
        }
        # restore locked data over anything read from file
        foreach v [array names data] {
                if {[info exists lock($v)] && [info exists lockdata($v)]} {
                        if {$lock($v)} {set data($v) $lockdata($v)}
                }
        }
        status
}
# copy filename into title field, minus year if present
proc filename_to_title {args} {
        global data
        set f $data(filename)
        # find year
        set idx [string last ( $f]
        if {($idx > -1) && ([string index $f $idx+5] == ")")} {
                set year [string range $f $idx+1 $idx+4]
                if {[string is integer $year]} {
                        set data(@day) $year
                        set data(@nam) [string trim [string range $f 0 $idx-1]]
                }
        } else {
                set data(@nam) [string trim [file rootname $f]]
        }
        log "copied filename to Title field" warn
}
# write file tags
proc write_file {args} {
        global var data lock lockdata
        if {[info exists data(filename)]} {set filename $data(filename); unset data(filename)} else {log "no filename given"; return}
        if {$args == "clear"} {log "Clearing existing tags..." warn}
        log "Writing tags to $filename"
        set time1 [clock seconds]
        set ext [string trimleft [string tolower [file extension $filename]] .]
        . configure -cursor watch
        log clear
        set err 0
        status "Writing..." black yellow
        # use appropriate tool for the file type mp4/mkv
        switch -- $ext {
                mp4 {
                        if {$var(overwrite)} {set params "--overWrite"} else {set params ""}
                        foreach v [array names data] {
                                # replace tag with parameter for parsley
                                # omit if data is blank, but then how to remove a tag?
                                switch -- $v {
                                @nam {append params " --title \"$data($v)\""}
                                @ART {append params " --artist \"$data($v)\""}
                                @alb {append params " --album \"$data($v)\""}
                                @grp {append params " --grouping \"$data($v)\""}
                                gnre {append params " --genre \"$data($v)\""}
                                @gen {append params " --genre \"$data($v)\""}
                                @day {append params " --year \"$data($v)\""}
                                @cmt {append params " --comment \"$data($v)\""}
                                @too {append params " --encodingTool \"$data($v)\""}
                                disc {append params " --disk \"$data($v)\""}
                                track {append params " --tracknum \"$data($v)\""}
                                stik {append params " --stik \"$data($v)\""}
                                tvnn {append params " --TVNetwork \"$data($v)\""}
                                tvsh {append params " --TVShowName \"$data($v)\""}
                                tven {append params " --TVEpisode \"$data($v)\""}
                                tvsn {append params " --TVSeasonNum \"$data($v)\""}
                                tves {append params " --TVEpisodeNum \"$data($v)\""}
                                bpm {append params " --bpm \"$data($v)\""}
                                cprt {append params " --copyright \"$data($v)\""}
                                artw { # only add artwork if filename present
                                        if {[string length $data($v)]} {
                                                append params " --artwork \"$data($v)\""
                                        }
                                        }
                                filename {}
                                default {log "unknown how to assign tag $v"}
                                }
                        }
                        if {$args == "clear"} {set params "-P --overWrite"}
                        puts "$var(MP4_EDIT) $filename $params"
                        # write to bat file then exec it, to support variable number of parameters
                        set fout [open update.bat w]
                        puts $fout "rem Script to update movie metadata, okay to delete"
                        puts $fout "\"$var(MP4_EDIT)\" \"$filename\" $params"
                        close $fout
                        if {[catch {exec update.bat} result]} {
                                log "error: $result" err
                                status "error writing" black red
                                after $var(gumball_delay) status
                                set err 1
                        } else {
                                log "success writing tags" ok
                                status "Success writing" black green2
                                after $var(gumball_delay) status
                        }
                }
                mkv {
                        # make date format YYYY-mm-ddTHH:MM:SS+zz:zz
                        set datelen [string length $data(@day)]
                        if {$datelen < 1} {set data(@day) [clock format [clock seconds] -format "%Y-%m-%dT%H:%M:%S+00:00"]}
                        if {$datelen == "4"} {append data(@day) [clock format [clock seconds] -format "-%m-%dT%H:%M:%S+00:00"]}
                        if {[catch {exec $var(MKV_EDIT) $filename --edit info --set "title=$data(@nam)" --set "writing-application=$data(@too)" --set "date=$data(@day)"} result]} {
                                log "error: $result" err
                                status "error writing" black red
                                after $var(gumball_delay) status
                                set err 1
                        } else {
                                log "success writing tags" ok
                                status "Success writing" black green2
                                after $var(gumball_delay) status
                        }
                }
                default {
                        log "unknown file type $ext"
                }
        }
        set time2 [clock seconds]
        set timed [expr {$time2 - $time1}]
        if {$timed > 2} {log "elapsed time $timed seconds"}
        # confirm by re-reading file
        if {!$err} {read_file}
        # delete bat file
        catch {file delete update.bat}
        . configure -cursor {}
}

2024-05-31 FB I am actively updating this script and will backport the changes here when it is tested stable again. Added artwork to MKV files, images onto the buttons, and more error handling and console support.

[Category movie] [Category Matroska]