Written by Gemini.
Could you express that as CSV with pipe | as the separator, the last columns should be track duration and start time.
If I have individual tracks ripped with itunes, could you write a python script (using ffprobe) to compile such a table from a list of filenames.
Could you use a main(), add argparse. An option, -e or --end, will add an END entry at the end with just track title END, no track number, and the CD duration as the cumulative time. A -m option or --ptmd for long, will prepend the Track|... line with "csvhead:", and wrap the result with "```csv sep=|" before, and "```" afterwards to use in a modified markdown.
#!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
# Supported audio extensions (iTunes commonly uses m4a, mp3, wav, flac, etc.)
AUDIO_EXTENSIONS = ('.m4a', '.mp3', '.flac', '.wav', '.aac', '.alac')
def get_track_metadata(filepath):
"""Uses ffprobe to extract track title, artist, track number, and duration."""
cmd = [
'ffprobe',
'-v',
'quiet',
'-print_format',
'json',
'-show_format',
'-show_streams',
filepath,
]
try:
result = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
data = json.loads(result.stdout)
except Exception as e:
print(f'Error reading {filepath}: {e}', file=sys.stderr)
return None
format_data = data.get('format', {})
tags = format_data.get('tags', {})
# Normalize tag keys to lowercase for flexible lookup across ID3/iTunes tags
tags_lower = {k.lower(): v for k, v in tags.items()}
artist = (
tags_lower.get('artist')
or tags_lower.get('album_artist')
or 'Unknown Artist'
)
title = (
tags_lower.get('title')
or os.path.splitext(os.path.basename(filepath))[0]
)
track_num = tags_lower.get('track', '')
# Clean up track numbers like "1/16" -> "01"
if '/' in track_num:
track_num = track_num.split('/')[0]
try:
track_num = f'{int(track_num):02d}'
except ValueError:
track_num = None
duration = float(format_data.get('duration', 0.0))
return {
'filepath': filepath,
'track': track_num,
'artist': artist,
'title': title,
'duration_sec': duration,
}
def format_seconds(seconds):
"""Converts seconds into MM:SS format."""
m, s = divmod(int(round(seconds)), 60)
return f'{m:02d}:{s:02d}'
def process_audio_files(target_paths, include_end=False, ptmd=False):
"""Reads audio tracks, calculates timings, and prints formatted CSV output."""
# Expand directory inputs to file lists
files = []
for path in target_paths:
if os.path.isdir(path):
dir_files = [
os.path.join(path, f)
for f in sorted(os.listdir(path))
if f.lower().endswith(AUDIO_EXTENSIONS)
]
files.extend(dir_files)
elif os.path.exists(path):
files.append(path)
if not files:
print('No valid audio files found.', file=sys.stderr)
return
tracks = []
for f in files:
meta = get_track_metadata(f)
if meta:
tracks.append(meta)
# Header prefix logic
header_prefix = 'csvhead:' if ptmd else ''
header = f'{header_prefix}Track|Artist|Title|Duration|Start Time'
output_lines = []
if ptmd:
output_lines.append('```csv sep=|')
output_lines.append(header)
cum_seconds = 0.0
for idx, track in enumerate(tracks, 1):
# Fallback to sequential index if metadata track number is missing
track_num = track['track'] if track['track'] else f'{idx:02d}'
start_time_str = format_seconds(cum_seconds)
duration_str = format_seconds(track['duration_sec'])
output_lines.append(
f"{track_num}|{track['artist']}|{track['title']}|{duration_str}|{start_time_str}"
)
cum_seconds += track['duration_sec']
# Optional -e / --end marker line
if include_end:
end_time_str = format_seconds(cum_seconds)
output_lines.append(f'||END||{end_time_str}')
if ptmd:
output_lines.append('```')
# Output to stdout
print('\n'.join(output_lines))
def main():
parser = argparse.ArgumentParser(
description='Generate a pipe-separated CSV tracklist with start timestamps using ffprobe.'
)
parser.add_argument(
'paths',
metavar='PATH',
nargs='+',
help='Audio file paths or folder directory containing ripped tracks.',
)
parser.add_argument(
'-e',
'--end',
action='store_true',
help='Append an END entry row with the total cumulative CD duration.',
)
parser.add_argument(
'-m',
'--ptmd',
action='store_true',
help='Prepend "csvhead:" to the header line and wrap output in a ```csv sep=| codeblock.',
)
args = parser.parse_args()
process_audio_files(args.paths, include_end=args.end, ptmd=args.ptmd)
if __name__ == '__main__':
main()
Example Output
For Jarre's Chronologie: With no arguments we get
Track|Artist|Title|Duration|Start Time
01|Jean Michel Jarre|Chronologie Part 1|10:53|00:00
02|Jean Michel Jarre|Chronologie Part 2|06:05|10:53
03|Jean Michel Jarre|Chronologie Part 3|04:00|16:58
04|Jean Michel Jarre|Chronologie Part 4|03:59|20:58
05|Jean Michel Jarre|Chronologie Part 5|05:35|24:57
06|Jean Michel Jarre|Chronologie Part 6|03:46|30:32
07|Jean Michel Jarre|Chronologie Part 7|02:17|34:18
08|Jean Michel Jarre|Chronologie Part 8|05:33|36:35
Then -e adds:
||END||42:08
With -m -e this results in markdown for this site:
```csv sep=|
csvhead:Track|Artist|Title|Duration|Start Time
01|Jean Michel Jarre|Chronologie Part 1|10:53|00:00
02|Jean Michel Jarre|Chronologie Part 2|06:05|10:53
03|Jean Michel Jarre|Chronologie Part 3|04:00|16:58
04|Jean Michel Jarre|Chronologie Part 4|03:59|20:58
05|Jean Michel Jarre|Chronologie Part 5|05:35|24:57
06|Jean Michel Jarre|Chronologie Part 6|03:46|30:32
07|Jean Michel Jarre|Chronologie Part 7|02:17|34:18
08|Jean Michel Jarre|Chronologie Part 8|05:33|36:35
||END||42:08
```
which renders as
| Track | Artist | Title | Duration | Start Time |
|---|---|---|---|---|
| 01 | Jean Michel Jarre | Chronologie Part 1 | 10:53 | 00:00 |
| 02 | Jean Michel Jarre | Chronologie Part 2 | 06:05 | 10:53 |
| 03 | Jean Michel Jarre | Chronologie Part 3 | 04:00 | 16:58 |
| 04 | Jean Michel Jarre | Chronologie Part 4 | 03:59 | 20:58 |
| 05 | Jean Michel Jarre | Chronologie Part 5 | 05:35 | 24:57 |
| 06 | Jean Michel Jarre | Chronologie Part 6 | 03:46 | 30:32 |
| 07 | Jean Michel Jarre | Chronologie Part 7 | 02:17 | 34:18 |
| 08 | Jean Michel Jarre | Chronologie Part 8 | 05:33 | 36:35 |
| END | 42:08 |