1
0
Files
Movie-Checker/movie_checker.py
2025-07-14 14:14:25 +02:00

440 lines
17 KiB
Python

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "PyQt5==5.15.9",
# "enzyme==0.5.2",
# ]
# ///
import sys
import os
import json
import subprocess
import hashlib
import enzyme
from PyQt5.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog, QLabel, QTableWidget, QTableWidgetItem, QHeaderView, QMenu, QTextEdit, QColorDialog
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
import shutil
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache')
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR)
def load_config():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
def save_config(config):
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
def list_mkv_files(folder):
if not folder or not os.path.isdir(folder):
return []
return sorted([f for f in os.listdir(folder) if f.lower().endswith('.mkv')], key=str.lower)
# Hilfsfunktion für Cache-Dateinamen
def cache_filename(filename):
# Dateiname ohne Endung, .json
base = os.path.splitext(filename)[0]
return os.path.join(CACHE_DIR, base + '.json')
# Prüfen, ob Cache aktuell ist
def is_cache_valid(mkv_path, cache_path):
if not os.path.exists(cache_path):
return False
try:
mkv_mtime = os.path.getmtime(mkv_path)
with open(cache_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get('_mtime') == mkv_mtime
except Exception:
return False
def clean_codec_name(codec):
if not codec:
return ''
# Entferne bekannte Präfixe
for prefix in [
'MPEG4/ISO/', 'MPEGH/ISO/', 'MS/VFW/FOURCC', 'MS/VFW/', 'ISO/', 'V_MPEG4/', 'V_MPEGH/', 'V_', 'MS/', 'A_', 'VFW/']:
if codec.upper().startswith(prefix):
return codec[len(prefix):].upper()
# Nur letzten Teil nach / nehmen, falls vorhanden
if '/' in codec:
return codec.split('/')[-1].upper()
return codec.upper()
# ffprobe als Fallback nutzen
# NEU: enzyme für Audio
def enzyme_audio_metadata(path):
try:
with open(path, 'rb') as f:
mkv = enzyme.MKV(f)
audio_infos = []
for a in mkv.audio_tracks:
codec = a.codec_id if hasattr(a, 'codec_id') else a.codec
# Präfix 'A_' entfernen
if codec and codec.upper().startswith('A_'):
codec = codec[2:]
# MPEG/L3 zu MP3, MPEG/L2 zu MP2
if codec and codec.upper() == 'MPEG/L3':
codec = 'MP3'
elif codec and codec.upper() == 'MPEG/L2':
codec = 'MP2'
channels = f"{a.channels}ch" if hasattr(a, 'channels') and a.channels else ''
sprache = a.language if hasattr(a, 'language') and a.language else ''
sprache_str = sprache if sprache else 'undefined'
info = f"{codec} {channels} {sprache_str}".strip()
audio_infos.append(info)
return audio_infos, None
except Exception as e:
return None, f"enzyme Exception: {e}"
def ffprobe_metadata(path):
try:
cmd = [
'ffprobe',
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height,codec_name,codec_tag_string,codec_tag,tags',
'-of', 'json',
path
]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if proc.returncode != 0:
return None, None, None, f"ffprobe Fehler: {proc.stderr.strip()}"
import json as _json
data = _json.loads(proc.stdout)
aufloesung = ''
video_codec = ''
debug_codec_candidates = []
if data.get('streams'):
s = data['streams'][0]
if s.get('width') and s.get('height'):
aufloesung = f"{s['width']}x{s['height']}"
# Codec-Name ermitteln
video_codec = s.get('codec_name', '')
debug_codec_candidates.append(('codec_name', video_codec))
# Fallback: codec_tag_string, codec_tag, tags
candidates = [
s.get('codec_tag_string'),
s.get('codec_tag'),
]
if s.get('tags'):
tags = s['tags']
for key in ['codec_tag_string', 'codec_id_hint', 'Codec-ID/Hint', 'codec_tag', 'fourcc', 'handler_name']:
if tags.get(key):
candidates.append(tags.get(key))
debug_codec_candidates.append((key, tags.get(key)))
# Nimm den ersten sinnvollen Wert, falls codec_name leer oder generisch
if (not video_codec or video_codec.upper() in ['FOURCC', 'VFW', 'MS', '', 'UNKNOWN']) and any(candidates):
for c in candidates:
if c and c.upper() not in ['FOURCC', 'VFW', 'MS', '', 'UNKNOWN']:
video_codec = c
break
video_codec = clean_codec_name(video_codec)
# Debug: Wenn immer noch leer, logge alle Kandidaten
if not video_codec or video_codec in ['FOURCC', 'VFW', 'MS', '', 'UNKNOWN']:
debug_str = f"ffprobe: Keine sinnvolle Codec-Info gefunden. Rohdaten: "
debug_str += str({k: v for k, v in debug_codec_candidates if v})
print(debug_str)
# Audio jetzt mit enzyme
audio_infos, enzyme_err = enzyme_audio_metadata(path)
if enzyme_err:
audio_infos = [f"Fehler: {enzyme_err}"]
return aufloesung, video_codec, audio_infos, None
except Exception as e:
return None, None, None, f"ffprobe Exception: {e}"
# Metadaten auslesen und cachen
class MetadataLoaderThread(QThread):
files_loaded = pyqtSignal(list)
debug_message = pyqtSignal(str)
def __init__(self, folder, files):
super().__init__()
self.folder = folder
self.files = files
def run(self):
result = []
for filename in self.files:
path = os.path.join(self.folder, filename)
cache_path = cache_filename(filename)
aufloesung = ''
video_codec = ''
audio_infos = []
# Cache prüfen
if is_cache_valid(path, cache_path):
try:
with open(cache_path, 'r', encoding='utf-8') as f:
data = json.load(f)
aufloesung = data.get('aufloesung', '')
video_codec = data.get('video_codec', '')
audio_infos = data.get('audio_infos', [])
except Exception as e:
self.debug_message.emit(f"Fehler beim Lesen des Caches für {filename}: {e}")
else:
# Nur noch ffprobe
aufloesung, video_codec, audio_infos, ffprobe_err = ffprobe_metadata(path)
if ffprobe_err or not aufloesung:
self.debug_message.emit(f"ffprobe Fehler bei {filename}: {ffprobe_err}")
aufloesung = 'Fehler'
video_codec = ''
audio_infos = [f'Fehler: {ffprobe_err}']
# Cache schreiben
try:
cache_data = {
'aufloesung': aufloesung,
'video_codec': video_codec,
'audio_infos': audio_infos,
'_mtime': os.path.getmtime(path)
}
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
except Exception as e:
self.debug_message.emit(f"Fehler beim Schreiben des Caches für {filename}: {e}")
# Auflösung und Video-Codec jetzt getrennt
result.append({
'filename': filename,
'aufloesung': aufloesung or '',
'video_codec': video_codec or '',
'audio': '; '.join(audio_infos)
})
self.files_loaded.emit(result)
class MovieChecker(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle('Movie Checker')
self.resize(800, 500)
self.config = load_config()
self.folder = self.config.get('movie_folder', '')
self.layout = QVBoxLayout()
self.setLayout(self.layout)
self.info_label = QLabel('Wähle einen Ordner mit MKV-Dateien:')
self.layout.addWidget(self.info_label)
self.select_btn = QPushButton('Ordner auswählen')
self.select_btn.clicked.connect(self.select_folder)
self.layout.addWidget(self.select_btn)
self.reload_btn = QPushButton('Cache leeren & neu einlesen')
self.reload_btn.clicked.connect(self.reload_and_clear_cache)
self.layout.addWidget(self.reload_btn)
self.loading_label = QLabel('')
self.loading_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.loading_label)
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(['Datei', 'Auflösung', 'Video-Codec', 'Audio'])
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_table_context_menu)
self.layout.addWidget(self.table)
self.debug_log = QTextEdit()
self.debug_log.setReadOnly(True)
self.debug_log.setMaximumHeight(120)
self.layout.addWidget(self.debug_log)
self.file_loader = None
self.meta_loader = None
self.spinner_active = False
self.spinner_frames = ['','','','','','','','','','']
self.spinner_index = 0
self.timer = None
self.load_files()
def select_folder(self):
folder = QFileDialog.getExistingDirectory(self, 'Filme-Ordner auswählen', self.folder or os.path.expanduser('~'))
if folder:
self.folder = folder
self.config['movie_folder'] = folder
save_config(self.config)
self.load_files()
def reload_and_clear_cache(self):
# Cache-Ordner leeren
if os.path.exists(CACHE_DIR):
for f in os.listdir(CACHE_DIR):
try:
os.remove(os.path.join(CACHE_DIR, f))
except Exception:
pass
self.load_files()
def start_spinner(self, text='Lade Dateien ...'):
if not self.spinner_active:
self.spinner_active = True
self.spinner_index = 0
from PyQt5.QtCore import QTimer
self.timer = QTimer(self)
self.timer.timeout.connect(lambda: self.update_spinner(text))
self.timer.start(80)
self.loading_label.setText(self.spinner_frames[self.spinner_index] + ' ' + text)
def stop_spinner(self):
if self.spinner_active and self.timer:
self.timer.stop()
self.spinner_active = False
self.loading_label.setText('')
def update_spinner(self, text):
self.spinner_index = (self.spinner_index + 1) % len(self.spinner_frames)
self.loading_label.setText(self.spinner_frames[self.spinner_index] + ' ' + text)
def log_debug(self, msg):
self.debug_log.append(msg)
def load_files(self):
self.table.setRowCount(0)
if not self.folder:
self.info_label.setText('Kein Ordner ausgewählt.')
return
self.info_label.setText(f'Aktiver Ordner: {self.folder}')
self.start_spinner('Lade Dateiliste ...')
files = list_mkv_files(self.folder)
self.on_files_loaded(files)
def on_files_loaded(self, files):
self.stop_spinner()
self.table.setRowCount(0)
self.debug_log.clear()
if not files:
self.table.setRowCount(1)
self.table.setItem(0, 0, QTableWidgetItem('Keine MKV-Dateien gefunden.'))
self.table.setItem(0, 1, QTableWidgetItem(''))
self.table.setItem(0, 2, QTableWidgetItem(''))
self.table.setItem(0, 3, QTableWidgetItem(''))
else:
self.start_spinner('Lese Metadaten ...')
self.meta_loader = MetadataLoaderThread(self.folder, files)
self.meta_loader.files_loaded.connect(self.on_metadata_loaded)
self.meta_loader.debug_message.connect(self.log_debug)
self.meta_loader.start()
def on_metadata_loaded(self, file_infos):
self.stop_spinner()
self.table.setRowCount(len(file_infos))
for row, info in enumerate(file_infos):
self.table.setItem(row, 0, QTableWidgetItem(info['filename']))
aufl_item = QTableWidgetItem(info['aufloesung'])
# Farbige Unterlegung je nach Breite
breite = None
try:
if info['aufloesung']:
breite = int(str(info['aufloesung']).split('x')[0])
except Exception:
pass
if breite is not None:
if breite < 1280:
aufl_item.setBackground(Qt.red)
elif breite < 1920:
aufl_item.setBackground(Qt.darkYellow)
elif breite < 3840:
aufl_item.setBackground(Qt.yellow)
else:
aufl_item.setBackground(Qt.green)
self.table.setItem(row, 1, aufl_item)
self.table.setItem(row, 2, QTableWidgetItem(info.get('video_codec', '')))
self.table.setItem(row, 3, QTableWidgetItem(info['audio']))
def show_table_context_menu(self, pos):
index = self.table.indexAt(pos)
if not index.isValid():
return
row = index.row()
col = index.column()
filename_item = self.table.item(row, 0)
if not filename_item:
return
filename = filename_item.text()
if filename == 'Keine MKV-Dateien gefunden.':
return
menu = QMenu(self)
reload_action = menu.addAction('Film neu einlesen (Cache löschen)')
# Option für Farbe wählen, nur für Auflösungsspalte
color_action = None
if col == 1:
color_action = menu.addAction('Farbe für diese Zelle wählen ...')
action = menu.exec_(self.table.viewport().mapToGlobal(pos))
if action == reload_action:
self.reload_single_file(filename)
elif color_action and action == color_action:
self.choose_cell_color(row, col)
def choose_cell_color(self, row, col):
item = self.table.item(row, col)
if not item:
return
color = QColorDialog.getColor()
if color.isValid():
item.setBackground(color)
def reload_single_file(self, filename):
# Cache-Datei löschen
cache_path = cache_filename(filename)
if os.path.exists(cache_path):
try:
os.remove(cache_path)
except Exception:
pass
# Nur diesen Film neu laden
self.start_spinner(f'Lese Metadaten für {filename} ...')
self.meta_loader = MetadataLoaderThread(self.folder, [filename])
self.meta_loader.files_loaded.connect(lambda infos: self.update_single_row(filename, infos[0] if infos else None))
self.meta_loader.debug_message.connect(self.log_debug)
self.meta_loader.start()
def update_single_row(self, filename, info):
self.stop_spinner()
for row in range(self.table.rowCount()):
if self.table.item(row, 0) and self.table.item(row, 0).text() == filename:
if info:
self.table.setItem(row, 0, QTableWidgetItem(info['filename']))
aufl_item = QTableWidgetItem(info['aufloesung'])
breite = None
try:
if info['aufloesung']:
breite = int(str(info['aufloesung']).split('x')[0])
except Exception:
pass
if breite is not None:
if breite < 1280:
aufl_item.setBackground(Qt.red)
elif breite < 1920:
aufl_item.setBackground(Qt.darkYellow)
elif breite < 3840:
aufl_item.setBackground(Qt.yellow)
else:
aufl_item.setBackground(Qt.green)
self.table.setItem(row, 1, aufl_item)
self.table.setItem(row, 2, QTableWidgetItem(info.get('video_codec', '')))
self.table.setItem(row, 3, QTableWidgetItem(info['audio']))
break
def main():
app = QApplication(sys.argv)
window = MovieChecker()
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()