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 {}
}