From c42a6c8a1bb4cc89b496f088603f90b6067cf52e Mon Sep 17 00:00:00 2001
From: bender <123113548+LeLawnGames@users.noreply.github.com>
Date: Wed, 25 Jan 2023 16:35:09 -0500
Subject: [PATCH] Add files via upload
---
00_exportchapters.py | 83 +++++++++++++++++++++++++
Order of operations.txt | 12 ++++
README.md | 2 +
chapter_ms.py | 33 ++++++++++
durations.py | 36 +++++++++++
extract_overdrive_chapters.py | 113 ++++++++++++++++++++++++++++++++++
final_metadata_add.py | 78 +++++++++++++++++++++++
metadatatoxml.py | 14 +++++
overdrive_full.sh | 41 ++++++++++++
overdrivedelete.sh | 41 ++++++++++++
xmlparse.py | 39 ++++++++++++
11 files changed, 492 insertions(+)
create mode 100644 00_exportchapters.py
create mode 100644 Order of operations.txt
create mode 100644 README.md
create mode 100644 chapter_ms.py
create mode 100644 durations.py
create mode 100644 extract_overdrive_chapters.py
create mode 100644 final_metadata_add.py
create mode 100644 metadatatoxml.py
create mode 100644 overdrive_full.sh
create mode 100644 overdrivedelete.sh
create mode 100644 xmlparse.py
diff --git a/00_exportchapters.py b/00_exportchapters.py
new file mode 100644
index 0000000..c14b15e
--- /dev/null
+++ b/00_exportchapters.py
@@ -0,0 +1,83 @@
+#Confirmed working as of 1/22/23
+
+import os
+import shutil
+from pydub import AudioSegment
+
+AudioSegment.converter = "/opt/homebrew/Cellar/ffmpeg/5.1.2_3/bin/ffmpeg"
+AudioSegment.ffmpeg = "/opt/homebrew/Cellar/ffmpeg/5.1.2_3/bin/ffmpeg"
+AudioSegment.ffprobe ="/opt/homebrew/Cellar/ffmpeg/5.1.2_3/bin/ffmpeg"
+
+def export_audio_file(start, end, name):
+ start_ms = int(start)
+ end_ms = int(end)
+ export = combined[start_ms:end_ms]
+ folder_name = folder.split(" - ")[-1].strip()
+ destination_path = '/Users/jonas/Documents/SERVER/BOOKS/01_LABELED AUDIO'
+ author_name = folder.split(" - ")[0].strip()
+ export_folder = os.path.join(destination_path, author_name, folder_name)
+ author_folder = os.path.join(destination_path, author_name)
+ if not os.path.exists(author_folder):
+ os.makedirs(author_folder)
+ export_folder = os.path.join(author_folder, folder_name)
+
+ if not os.path.exists(export_folder):
+ os.makedirs(export_folder)
+ export_path = os.path.join(export_folder, f"{name}.mp3")
+ export.export(export_path, format="mp3")
+
+ metadata_file = os.path.join(folder_path, "cleaned_metadata.json")
+ chapters_file = os.path.join(folder_path, "overdrive_chapters_ms.txt")
+ album_art = os.path.join(folder_path, "folder.jpg")
+ if os.path.exists(album_art):
+ shutil.copy(album_art, export_folder)
+ shutil.copy(metadata_file, export_folder)
+
+main_directory = "/Users/jonas/Documents/SERVER/BOOKS/TEST"
+
+for folder in sorted(os.listdir(main_directory)):
+ folder_path = os.path.join(main_directory, folder)
+ if os.path.isdir(folder_path):
+ try:
+ # Import MP3s in alphabetical order
+ mp3_files = sorted(os.listdir(folder_path))
+ mp3_files = [os.path.join(folder_path, file) for file in mp3_files if file.endswith(".mp3")]
+
+ combined = AudioSegment.empty()
+ for mp3_file in mp3_files:
+ print(mp3_file)
+ combined += AudioSegment.from_file(mp3_file)
+
+ # Align end to end
+ combined = combined.set_channels(1)
+
+ # Import labels
+ labels_file = os.path.join(folder_path, "overdrive_chapters_ms_spans.txt")
+ labels = []
+ with open(labels_file, "r") as f:
+ for line in f:
+ start, end, name = line.strip().split("\t")
+ labels.append((start, end, name))
+
+ # Initialize counter for duplicate label names
+ counter = {}
+
+ # Initialize counter for file export
+ file_count = 0
+
+ for i, label in enumerate(labels):
+ start, end, name = label
+ if end == "0" and i == len(labels) - 1:
+ end = combined.duration_seconds * 1000
+
+ file_count += 1
+ name = f"{file_count:03}_{name.replace('/', '_')}"
+ # format file_count as 3 digit number
+ export_audio_file(start, end, name)
+
+ except Exception as e:
+ print(f"Error processing {folder}: {e}")
+
+ #Archive everything except chapters_list.py
+ if folder_path != "chapters_list.py":
+ shutil.move(folder_path, '/Users/jonas/Documents/SERVER/BOOKS/00_ARCHIVE/' + folder)
\ No newline at end of file
diff --git a/Order of operations.txt b/Order of operations.txt
new file mode 100644
index 0000000..fced2da
--- /dev/null
+++ b/Order of operations.txt
@@ -0,0 +1,12 @@
+Order of operations:
+
+01: Download .odm files
+02: Use sh overdrivedelete.sh to unpack
+03: Use chapters_list.py to get the chapters times and titles
+04: Use metadatatoxml.py to get xml
+05: Use xmlparse.py to clean metadata
+06: Use chapter_ms.py to put the chapter times in ms
+07: Use durations.py to create the duration spans in the chapter file
+08: Use 00_exportchapters.py to get the labeled audio, pull the metadata txt & folder.jpg into the new folder, and move the old folder to archive
+09: Use final_metadata_add.py to add metadata to the tracks and move the author folders to 02_READY FOR PLEX
+10: Drag to Plex -- the odm download and this part are the two manuals components and I consider those safeguards. Worst case scenario if something went wrong you'd probably see it during this step and you could revert to the archives.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e595269
--- /dev/null
+++ b/README.md
@@ -0,0 +1,2 @@
+# odm-plex
+Automates the process of opening ODM's and formatting them for use in Plex including editing down to chapters and adding metadata.
diff --git a/chapter_ms.py b/chapter_ms.py
new file mode 100644
index 0000000..eef606a
--- /dev/null
+++ b/chapter_ms.py
@@ -0,0 +1,33 @@
+#Confirmed working as of 1/22/23
+
+import os
+import re
+from datetime import datetime, timedelta
+
+def duration_to_milliseconds(duration):
+ hours, minutes, seconds = duration.split(':')
+ seconds = int(hours)*3600 + int(minutes)*60 + float(seconds)
+ return int(seconds * 1000)
+
+# path to directory containing the files
+directory = '/Users/jonas/Documents/SERVER/BOOKS/TEST'
+
+# loop through all files in directory tree
+for dirpath, dirnames, filenames in os.walk(directory):
+ for filename in filenames:
+ if filename.startswith("overdrive_chapters") and filename.endswith(".txt"):
+ # open the file and read the contents
+ with open(os.path.join(dirpath, filename), 'r') as f:
+ lines = f.readlines()
+
+ # create a new file with the same name but with "milliseconds" added to the end
+ new_filename = filename[:-4] + "_ms.txt"
+ with open(os.path.join(dirpath, new_filename), 'w') as new_file:
+ # loop through each line in the file
+ for line in lines:
+ # split the line into duration and label using regular expression
+ duration, label = re.split(r"\s+", line.strip(), 1)
+ # convert the duration to milliseconds
+ milliseconds = duration_to_milliseconds(duration)
+ # write the converted duration and label to the new file
+ new_file.write(f'{milliseconds} {label}\n')
diff --git a/durations.py b/durations.py
new file mode 100644
index 0000000..f93cc3b
--- /dev/null
+++ b/durations.py
@@ -0,0 +1,36 @@
+#Confirmed working as of 1/22/23
+
+import os
+
+root_dir = '/Users/jonas/Documents/SERVER/BOOKS/TEST'
+for subdir, dirs, files in os.walk(root_dir):
+ for file in files:
+ file_path = os.path.join(subdir, file)
+ if file == 'overdrive_chapters_ms.txt':
+ with open(file_path, "r") as file:
+ lines = file.read().split('\n')
+ new_file_path = file_path.replace('.txt', '_spans.txt')
+ with open(new_file_path, "w") as file:
+ for i in range(len(lines)):
+ parts = lines[i].split(" ")
+ if i == len(lines) - 2:
+ # If it's the second-to-last line, write the last line's number and 0
+ file.write(parts[0])
+ file.write("\t" + lines[i + 1].split(" ")[0])
+ file.write("0")
+ elif i != len(lines) - 1:
+ # Otherwise, write the number from the next line to the file
+ file.write(parts[0])
+ file.write("\t" + lines[i + 1].split(" ")[0])
+ else:
+ # If it's the last line, just write the number and 0
+ if parts[0] != "":
+ file.write(parts[0])
+ file.write("0")
+ # Write the rest of the line to the file
+ file.write("\t" + " ".join(parts[1:]))
+ if i != len(lines) - 1 or (i == len(lines) - 1 and lines[i] != ""):
+ file.write("\n")
+ if lines[-1] == "":
+ file.seek(file.tell()-1, os.SEEK_SET)
+ file.truncate()
\ No newline at end of file
diff --git a/extract_overdrive_chapters.py b/extract_overdrive_chapters.py
new file mode 100644
index 0000000..97ca766
--- /dev/null
+++ b/extract_overdrive_chapters.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+# Credit to ex-nerd
+#
+# Recursively scans current or specified directory for all subdirectories
+# containing mp3 files. If these mp3 files contain overdrive chapter markers
+# (id3 tag), writes overdrive_chapters.txt to the same directory.
+#
+# Usage:
+#
+# extract_overdrive_chapters.py [optional directory path]
+#
+# Use with build_m4b from https://github.com/ex-nerd/audiotools
+#
+# Note: Due to overdrive low quality, there is no point in encoding aac files
+# with better than: 64kbps stereo, HE, optimize for voice
+#
+
+import os, sys, re
+import mutagen.id3 as id3
+from mutagen.mp3 import MP3
+from mutagen import File
+
+from collections import OrderedDict
+
+def timestr(secs):
+ (secs, ms) = str(secs).split(".")
+ ms = float(ms[0:3] + "." + ms[3:])
+ secs = int(secs)
+ hours = int(secs // 3600)
+ secs = secs % 3600
+ mins = int(secs // 60)
+ secs = secs % 60
+ return f"{hours:02}:{mins:02}:{secs:02}.{ms:03.0f}"
+
+
+def load_mp3(total, dir, file):
+ path = os.path.join(dir, file)
+ audio = MP3(path)
+ # print(audio.info.length) # , audio.info.bitrate
+ m = id3.ID3(path)
+
+ data = m.get("TXXX:OverDrive MediaMarkers")
+ if not data:
+ print("Can't find TXXX data point for {0}".format(file))
+ print(m.keys())
+ return
+ info = data.text[0]
+ file_chapters = re.findall(
+ r"\s*([^>]+?)\s*", info, re.MULTILINE
+ )
+ chapters = []
+ for chapter in file_chapters:
+ (name, length) = chapter
+ name = re.sub(r'^"(.+)"$', r"\1", name)
+ name = re.sub(r"^\*(.+)\*$", r"\1", name)
+ name = re.sub(
+ r"\s*\([^)]*\)$", "", name
+ ) # ignore any sub-chapter markers from Overdrive
+ name = re.sub(
+ r"\s+\(?continued\)?$", "", name
+ ) # ignore any sub-chapter markers from Overdrive
+ name = re.sub(
+ r"\s+-\s*$", "", name
+ ) # ignore any sub-chapter markers from Overdrive
+ name = re.sub(
+ r"^Dis[kc]\s+\d+\W*$", "", name
+ ) # ignore any disk markers from Overdrive
+ name = name.strip()
+ t_parts = list(length.split(":"))
+ t_parts.reverse()
+ seconds = total + float(t_parts[0])
+ if len(t_parts) > 1:
+ seconds += int(t_parts[1]) * 60
+ if len(t_parts) > 2:
+ seconds += int(t_parts[2]) * 60 * 60
+ chapters.append([name, seconds])
+ # print(name, seconds)
+ return (total + audio.info.length, chapters)
+
+
+def visit(dirname, filenames):
+ print(dirname)
+ os.chdir(dirname)
+ # Parse the files
+ total = 0
+ all_chapters = OrderedDict()
+ for file in sorted(filenames):
+ if file.endswith(".mp3"):
+ (total, chapters) = load_mp3(total, dirname, file)
+ # print(repr(chapters))
+ for chapter in chapters:
+ if chapter[0] in all_chapters.keys():
+ continue
+ all_chapters[chapter[0]] = chapter[1]
+ if len(all_chapters) > 0:
+ with open("overdrive_chapters.txt", "w") as file:
+ for name, length in all_chapters.items():
+ chapstr = f"{timestr(length)} {name}"
+ print(chapstr)
+ file.write(chapstr + "\n")
+ # print(repr(all_chapters))
+
+
+if __name__ == "__main__":
+
+ if len(sys.argv) > 1:
+ path = os.path.abspath(sys.argv[1])
+ else:
+ path = os.path.abspath(".")
+
+ for dirname, dirs, files in os.walk(path, topdown=True):
+ dirs[:] = [d for d in dirs if d not in {".git", ".direnv"}]
+ visit(dirname, files)
diff --git a/final_metadata_add.py b/final_metadata_add.py
new file mode 100644
index 0000000..0287ea2
--- /dev/null
+++ b/final_metadata_add.py
@@ -0,0 +1,78 @@
+#Confirmed working as of 1/23/23
+
+import os
+import shutil
+import json
+import re
+from mutagen.id3 import ID3, APIC, TIT2, TPE1, TCOM, TCON, TSOA, TRCK, TIT3, TALB, COMM
+
+def traverse_directory(directory):
+ # Iterate through all subfolders and files
+ for root, dirs, files in os.walk(directory):
+ for dir in dirs:
+ subfolder_path = os.path.join(root, dir)
+ if os.path.exists(os.path.join(subfolder_path, "cleaned_metadata.json")):
+ add_metadata(subfolder_path)
+
+def add_metadata(directory):
+ # Get the metadata from the cleaned_metadata.json file in the current subfolder
+ with open(os.path.join(directory, "cleaned_metadata.json"), "r") as f:
+ json_data = json.load(f)
+ artist = json_data.get("Author", "")
+ composer = json_data.get("Narrator", "")
+ genre = json_data.get("Subjects", "")
+ album_title = json_data.get("Title", "")
+
+ # Rest of the code
+
+ # Replace all occurrences of -s with 's in the album title
+ album_title = re.sub("-s", "'s", album_title)
+
+ # Get a list of all mp3 files in the current directory
+ mp3_files = [f for f in os.listdir(directory) if f.endswith(".mp3")]
+
+ # Sort the list of mp3 files alphabetically
+ mp3_files.sort()
+
+ # Iterate through the list of mp3 files
+ for i, mp3_file in enumerate(mp3_files, 1):
+ mp3_file_path = os.path.join(directory, mp3_file)
+
+ # Extract the title of the mp3 file
+ file_name, file_ext = os.path.splitext(mp3_file)
+ title = file_name.lstrip("0123456789_")
+
+ # Add the track number to the file using ID3
+ audio = ID3(mp3_file_path)
+ audio.add(TRCK(encoding=3, text=str(i)))
+ audio.add(TIT2(encoding=3, text=title))
+ audio.save()
+
+ # Add the metadata to the file using ID3
+ audio = ID3(mp3_file_path)
+ audio.add(TPE1(encoding=3, text=artist))
+ audio.add(TCOM(encoding=3, text=composer))
+ audio.add(TCON(encoding=3, text=genre))
+ audio.add(TALB(encoding=3, text=album_title))
+ audio.save()
+
+ # Check if folder.jpg exists in the current directory
+ if os.path.exists(os.path.join(directory, "folder.jpg")):
+ # Add the album art to the file using ID3
+ with open(os.path.join(directory, "folder.jpg"), "rb") as albumart:
+ audio = ID3(mp3_file_path)
+ audio.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=albumart.read()))
+ audio.save()
+
+# Point the script at a directory
+directory = "/Users/jonas/Documents/SERVER/BOOKS/01_LABELED AUDIO"
+
+# Traverse the directory and apply the metadata to the mp3 files
+traverse_directory(directory)
+
+for root, dirs, files in os.walk(directory):
+ for dir in dirs:
+ subfolder_path = os.path.join(root, dir)
+ parent_folder = os.path.dirname(subfolder_path)
+ if parent_folder == directory:
+ shutil.move(subfolder_path, "/Users/jonas/Documents/SERVER/BOOKS/02_READY FOR PLEX")
\ No newline at end of file
diff --git a/metadatatoxml.py b/metadatatoxml.py
new file mode 100644
index 0000000..33c6902
--- /dev/null
+++ b/metadatatoxml.py
@@ -0,0 +1,14 @@
+#Confirmed working as of 1/22/23
+
+import os
+import shutil
+
+root_dir = '/Users/jonas/Documents/SERVER/BOOKS/TEST'
+
+for subdir, dirs, files in os.walk(root_dir):
+ for file in files:
+ if file.endswith('.odm.metadata'):
+ old_file = os.path.join(subdir, file)
+ new_file = os.path.join(subdir, "metadata.xml")
+ shutil.copy2(old_file, new_file)
+ break
diff --git a/overdrive_full.sh b/overdrive_full.sh
new file mode 100644
index 0000000..0a0b0f3
--- /dev/null
+++ b/overdrive_full.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+
+#download odm's
+
+# NOTE: The odm's come with metadata -- for the future system use that file because it's more reliable.
+
+# navigate to the folder where the first script is located
+cd /Users/jonas/Documents/SERVER/FRAMEWORK
+
+# unpack odm's
+bash overdrivedelete.sh
+
+# navigate to the folder where the second script is located
+cd /Users/jonas/Documents/SERVER/BOOKS/TEST
+
+# extract chapters
+python3.10 extract_overdrive_chapters.py /Users/jonas/Documents/SERVER/BOOKS/01_ANNEX
+
+# navigate back to the directory where the shell script is located
+cd /Users/jonas/Documents/SERVER/LOCKED_CODE
+
+# clean metadata
+#python3.10 00_format_metadata.py
+python3.10 metadatatoxml.py
+
+python3.10 xmlparse.py
+
+# turn chapters into ms
+python3.10 chapter_ms.py
+
+# format durations
+python3.10 durations.py
+
+# export labeled audio and archive original folder
+# FIX: MAKE SURE IT CAN ADD TO EXISTING AUTHOR FOLDER
+python3.10 00_exportchapters.py
+
+# tag the exported audio with proper metadata
+python3.10 final_metadata_add.py
+
+#manually add to Plex
diff --git a/overdrivedelete.sh b/overdrivedelete.sh
new file mode 100644
index 0000000..83ea478
--- /dev/null
+++ b/overdrivedelete.sh
@@ -0,0 +1,41 @@
+#This officially works for the full ODM extraction as of 1/21/23 at 9:54pm
+
+input_folder="/Users/jonas/Documents/SERVER/QUEUE"
+output_folder="/Users/jonas/Documents/SERVER/BOOKS/01_ANNEX"
+
+# Open ODM's in temporary directory and export contents
+for file in $input_folder/*.odm
+do
+ # Make temp dir in loop so that it clears even if there's any errors
+ temp_dir=$(mktemp -d)
+ cd "$temp_dir"
+
+ echo "folder path: $input_folder"
+ echo "temp_dir path: $temp_dir"
+
+ while true; do
+ if ~/.local/bin/overdrive download $file; then
+ break
+ fi
+ sleep 2 # wait for 2 seconds before retrying
+ done
+
+ # Get the name of the downloaded folder
+ downloaded_folder=$(find "$temp_dir" -type d -mindepth 1 -maxdepth 1 | head -n 1)
+ echo "downloaded_folder: $downloaded_folder"
+
+ # Move the downloaded folder to the destination
+ mv "$downloaded_folder" "$output_folder"
+
+ # Move the .odm.metadata to the downloaded folder
+ metadata_file=$(find "$input_folder" -name "*.odm.metadata")
+ mv "$metadata_file" "$output_folder/$(basename "$downloaded_folder")"
+
+ # Delete leftover .odm and .odm.license files
+ odm=$(find "$input_folder" -name "*.odm")
+ license=$(find "$input_folder" -name "*.odm.license")
+ rm $odm
+ rm $license
+
+ rm -rf "$temp_dir"
+done
\ No newline at end of file
diff --git a/xmlparse.py b/xmlparse.py
new file mode 100644
index 0000000..3b89f40
--- /dev/null
+++ b/xmlparse.py
@@ -0,0 +1,39 @@
+#Confirmed as of 1/22/23
+
+import os
+import xml.etree.ElementTree as ET
+import json
+
+def extract_metadata(xml_file):
+ tree = ET.parse(xml_file)
+ root = tree.getroot()
+ data = {}
+ for child in root:
+ if child.tag == "Title":
+ data["Title"] = child.text
+ elif child.tag == "Creators":
+ for creator in child:
+ if creator.attrib["role"] == "Author":
+ data["Author"] = creator.text
+ elif creator.attrib["role"] == "Narrator":
+ data["Narrator"] = creator.text
+ elif child.tag == "Subjects":
+ subjects = []
+ for subject in child:
+ subjects.append(subject.text)
+ data["Subjects"] = ", ".join(subjects)
+ return data
+
+def process_folder(folder_path):
+ for dirpath, dirnames, filenames in os.walk(folder_path):
+ for filename in filenames:
+ if filename == "metadata.xml":
+ xml_file = os.path.join(dirpath, filename)
+ metadata = extract_metadata(xml_file)
+ cleaned_file = os.path.join(dirpath, "cleaned_metadata.json")
+ with open(cleaned_file, "w") as f:
+ json.dump(metadata, f)
+ print(f"Processed {xml_file}, output saved to {cleaned_file}")
+
+folder_path = "/Users/jonas/Documents/SERVER/BOOKS/TEST"
+process_folder(folder_path)
\ No newline at end of file