Batchrenamer für US amerikanische Fernsehserien

Stellt hier eure Projekte vor.
Internetseiten, Skripte, und alles andere bzgl. Python.
Antworten
Benutzeravatar
waldbaer
User
Beiträge: 4
Registriert: Donnerstag 2. Juni 2011, 17:31

Hallo liebe Community,

nachdem ich letztes Wochenende über das "Google's Python Class" (wer es nicht kennt einfach mal nach googeln) Videotutorial gestolpert bin und es mir angeschaut sowie die Übungen durchgearbeitet habe, wollte ich euch nun die Umsetzung meines ersten Projekts präsentieren.

Das Script dient dazu alle, in dem an das Script übergebenen Verzeichnis befindlichen Dateien umzubenennen.
Hier ein paar Beispiele:

bones.S05E19.720p.X264-DIMENSION.mkv --> Bones - 103 - 05x19 - The Rocker in the Rinse Cycle.mkv
Game.of.Thrones.S01E05.720p.HDTV.X264.mkv --> Game of Thrones - 005 - 01x05 - The Wolf and the Lion.mkv
Glee.S01E20.720p.HDTV.X264-ctu.mkv --> Glee - 020 - 01x20 - Theatricality.mkv
the.mentalist.S03E07.720p.HDTV.X264-DIMENSION.mkv --> The Mentalist - 053 - 03x07 - Red Hot.mkv

Das Script tut genau das was es soll, da es sich hierbei aber um meine ersten Gehversuche in Sachen Python handelt und ich gerne dazu lernen möchte, würde ich mich über konstruktive Kritik wie man das Script noch optimieren könnte, freuen.

Gruß waldbaer

Das Script:

Code: Alles auswählen

#!/usr/local/bin/python
# -*- mode: python;  coding: utf-8; -*-
# Copyright 2011 Markus Hoffmann

from xml.etree import ElementTree as et
import urllib2
import sys
import os
import re
import glob
import shutil

def get_filenames(dir):
    filenames = glob.glob(dir + '\*.*')
    return filenames

def build_rename_queue(filenames, dir):
    queue = []
    for filename in filenames:
        analysed_filename = analyse_filename(filename, dir)
        showid = get_showid(analysed_filename, dir)
        episodename = get_episodename(showid, dir)
        new_filename = generate_new_filename(episodename)
        tuple = (new_filename['old_filename'], dir + '\\' + clear_filename(new_filename['new_filename']))
        queue.append(tuple)
    return queue

def analyse_filename(filename, dir):
    analysed_filename = {}
    analysed_filename['old_filename'] = filename
    result = re.search(r'([\w.]+)s(\d\d)e(\d\d)[\w.-]+(\.\w\w\w)', filename.replace(dir + '\\', '').lower())
    if result:
        analysed_filename['showname'] = result.group(1)[:-1].replace('.' , ' ')
        analysed_filename['season'] = int(result.group(2))
        analysed_filename['episode'] = int(result.group(3))
        analysed_filename['extension'] = result.group(4)
    else:
        print "Couldn't analyse filename"
        return
    return analysed_filename

def get_showid(analysed_filename, dir):
    if not os.path.exists(os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_showid_' + analysed_filename['showname'] + '.xml'))):
        generate_xmlshowid(analysed_filename, dir)
    xml_feed = open(os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_showid_' + analysed_filename['showname'] + '.xml')), 'rU')
    tree = et.parse(xml_feed)
    xml_feed.close
    shows = tree.findall('show')
    for show in shows:
        tmp_showid = show.find("showid").text
        tmp_showname = show.find("name").text
        if tmp_showname.lower() == analysed_filename['showname']:
            analysed_filename['showname'] = tmp_showname
            analysed_filename['showid'] = tmp_showid
    return analysed_filename

def generate_xmlshowid(analysed_filename, dir):
    stripped_showname = analysed_filename['showname'].replace('the ', '')
    xml_url = r'http://services.tvrage.com/feeds/search.php?show=' + stripped_showname
    if not os.path.exists(os.path.abspath(dir + '\\tmp')):
        os.mkdir(os.path.abspath(dir + '\\tmp'))
    xml_feed = urllib2.urlopen(xml_url).read()
    f = open(os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_showid_' + analysed_filename['showname'] + '.xml')), 'w')
    f.write(xml_feed)
    f.close

def get_episodename(showid, dir):
    if not os.path.exists(os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_series_' + showid['showname'] + '.xml'))):
        generate_xmlseries(showid, dir)
    xml_feed = open(os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_series_' + showid['showname'] + '.xml')), 'rU')
    tree = et.parse(xml_feed)
    xml_feed.close
    seasons = tree.findall('Episodelist/Season')
    if seasons:
        for season in seasons:
            if int(season.get('no')) == showid['season']:
                episodes = season.findall('episode')
                for episode in episodes:
                    if int(episode.find('seasonnum').text) == showid['episode']:
                        showid['episodename'] = episode.find('title').text
                        showid['absolut_number'] = episode.find('epnum').text
    else:
        print "Couldn't parse XML file - " + os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_series_' + showid['showname'] + '.xml'))
    return showid

def generate_xmlseries(analysed_filename, dir):
    xml_url = r'http://services.tvrage.com/feeds/episode_list.php?sid=' + analysed_filename['showid']
    if not os.path.exists(os.path.abspath(dir + '\\tmp')):
        os.mkdir(os.path.abspath(dir + '\\tmp'))
    xml_feed = urllib2.urlopen(xml_url).read()
    f = open(os.path.abspath(os.path.join(dir + '\\tmp', 'tmp_series_' + analysed_filename['showname'].lower() + '.xml')), 'w')
    f.write(xml_feed)
    f.close

def generate_new_filename(episodename):
    episodename['new_filename'] = episodename['showname'] + ' - ' + '%0*d' % (3, int(episodename['absolut_number'])) + ' - ' + '%0*d' % (2, episodename['season']) + 'x' + '%0*d' % (2, episodename['episode']) + ' - ' + episodename['episodename'] + episodename['extension']
    return episodename

def clear_filename(filename):
    bad_chars = ['\\', '\/', '\?', '\:', '\*', '\"', '\<', '\>', '\|']
    clean_filename = filename
    for bad_char in bad_chars:
        clean_filename = clean_filename.replace(bad_char, ' ')
    return clean_filename

def propose_rename_queue(queue, dir):
    print
    print 'Rename Proposal:'
    print '================'
    print
    for tuple in queue:
        print tuple[0].replace(dir + '\\', '') + ' -->\n' + tuple[1].replace(dir + '\\', '')
        print
    choice = raw_input('Execute rename? (y)es or (n)o --> ')
    return choice

def execute_rename_queue(choice, queue):
    if choice == 'y':
        for names in queue:
            if names[1] != 'Episode not available':
                os.rename(names[0], names[1])
    elif choice == 'n':
        print 'Proposal rejected'
        return
    else:
        print 'You have to enter y or n'
    return

def main():
    if len(sys.argv) != 2:
        print 'usage: xmlparser.py dir'
        sys.exit(1)
    dir = sys.argv[1]
    filenames = get_filenames(dir)
    queue = build_rename_queue(filenames, dir)
    choice = propose_rename_queue(queue, dir)
    execute_rename_queue(choice, queue)
    shutil.rmtree(os.path.abspath(os.path.join(dir + '\\tmp')))
    return

if __name__ == '__main__':
  main()
Beispiel für tmp_showid_<showname>.xml:

Code: Alles auswählen

<?xml version="1.0" encoding="UTF-8" ?>

<Results>
<show><showid>8159</showid><name>The Game</name><link>http://www.tvrage.com/The_Game</link><country>US</country><started>2006</started><ended>0</ended><seasons>5</seasons><status>Returning Series</status><classification>Scripted</classification><genres><genre>Comedy</genre><genre>Sports</genre></genres></show>
<show><showid>24493</showid><name>Game of Thrones</name><link>http://www.tvrage.com/Game_of_Thrones</link><country>US</country><started>2010</started><ended>0</ended><seasons>1</seasons><status>Returning Series</status><classification>Scripted</classification><genres><genre>Action</genre><genre>Drama</genre><genre>Fantasy</genre></genres></show>
<show><showid>14649</showid><name>Wicked Wicked Games</name><link>http://www.tvrage.com/Wicked_Wicked_Games</link><country>US</country><started>2006</started><ended>2007</ended><seasons>1</seasons><status>Canceled/Ended</status><classification>Scripted</classification><genres><genre>Soaps</genre></genres></show>
</Results>
Beispiel für tmp_series_<showname>.xml:

Code: Alles auswählen

<?xml version="1.0" encoding="UTF-8" ?>


<Show>
<name>Game of Thrones</name>
<totalseasons>1</totalseasons>
<Episodelist>

<Season no="1">
<episode><epnum>1</epnum><seasonnum>01</seasonnum><prodnum></prodnum><airdate>2011-04-17</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065008299</link><title>Winter is Coming</title></episode>
<episode><epnum>2</epnum><seasonnum>02</seasonnum><prodnum></prodnum><airdate>2011-04-24</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065023912</link><title>The Kingsroad</title></episode>
<episode><epnum>3</epnum><seasonnum>03</seasonnum><prodnum></prodnum><airdate>2011-05-01</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065027990</link><title>Lord Snow</title></episode>
<episode><epnum>4</epnum><seasonnum>04</seasonnum><prodnum></prodnum><airdate>2011-05-08</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065029350</link><title>Cripples, Bastards, and Broken Things</title></episode>
<episode><epnum>5</epnum><seasonnum>05</seasonnum><prodnum></prodnum><airdate>2011-05-15</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065036402</link><title>The Wolf and the Lion</title></episode>
<episode><epnum>6</epnum><seasonnum>06</seasonnum><prodnum></prodnum><airdate>2011-05-22</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065036403</link><title>A Golden Crown</title></episode>
<episode><epnum>7</epnum><seasonnum>07</seasonnum><prodnum></prodnum><airdate>2011-05-29</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065036404</link><title>You Win or You Die</title></episode>
<episode><epnum>8</epnum><seasonnum>08</seasonnum><prodnum></prodnum><airdate>2011-06-05</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065039305</link><title>The Pointy End</title></episode>
<episode><epnum>9</epnum><seasonnum>09</seasonnum><prodnum></prodnum><airdate>2011-06-12</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065039306</link><title>Baelor</title></episode>
<episode><epnum>10</epnum><seasonnum>10</seasonnum><prodnum></prodnum><airdate>2011-06-19</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065039307</link><title>Fire and Blood</title></episode>
</Season>

<Special>
<episode><season>1</season><airdate>2010-12-05</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1064997231</link><title>Inside Game of Thrones</title></episode>
<episode><season>1</season><airdate>2011-04-03</airdate><link>http://www.tvrage.com/Game_of_Thrones/episodes/1065027989</link><title>15-Minute Preview</title></episode>
</Special>

</Episodelist>

</Show>
BlackJack

@waldbaer: `dir` ist der Name einer eingebauten Funktion. Die sollte man nicht an andere Werte binden. Das gleiche gilt auch für `tuple`.

Wenn man Pfadnamen mit `os.path.join()` zusammen setzt, dann ist das Programm an der Stelle plattformunabhängig. Gerade bei diesem Programm sehe ich nicht warum man das unnötig an Windows binden sollte. Ist sicher auch für Linux- oder Mac-Benutzer interessant. An einigen Stellen verwendest Du das sogar, aber dann *trotzdem* mit manuellen '\' in Pfadteilen!?

Hätte es bei `get_filenames()` nicht auch einfach `os.listdir()` anstelle von `glob()` getan?

`analyse_filename()` gibt im Fehlerfall einen Text aus und `None` zurück. Was im weiteren Programmverlauf dann an anderer Stelle zu einer Ausnahme führt (``TypeError: 'NoneType' object is unsubscriptable``). An der Stelle wird eine Ausgabe für den Benutzer mit der Programmlogik vermischt und ein Fehler im Grunde ignoriert solange bis es kracht. An der Stelle sollte man besser eine Ausnahme auslösen und die dann an entsprechender Stelle im Programm behandeln.

Wenn Du in der Funktion den Dateinamen mit `os.path.basename()` vom Pfad trennst, brauchst Du `dir` nicht, und es funktioniert auch unter anderen Betriebssystemen.

Du hast im Programm mehrere Male `close` stehen ohne es auch *aufzurufen*. So werden die Dateien nicht geschlossen. Am besten verwendet man sowieso die ``with``-Anweisung um sicher zu stellen, dass Dateien auch wieder geschlossen werden.

Man sollte Quelltextwiederholungen vermeiden (DRY-Prinzip (”Don't Repeat Yourself”). Wenn man einen längeren Ausdruck hat um einen Dateinamen zusammen zu setzen und den einmal hinschreibt um zu testen ob die Datei existiert und dann gleich noch einmal um sie zu öffnen oder anzulegen, dann sollte man das Ergebnis an einen Namen binden und den Ausruck nur einmal schreiben. Das gilt um so mehr für ganze Funktionen die nahezu identisch sind, wie `generate_xmlshowid()` und `generate_xmlseries()`.

Test ob eine Datei existiert ist zu einem gewissen Grad ”unpythonisch”. Meistens versucht man einfach sie zu öffnen und falls sie nicht existiert, reagiert man entsprechend auf die Ausnahme. Denn so ein Test garantiert ja nicht, das die Datei die eben noch da war, zum Zeitpunkt des öffnens immer noch vorhanden ist. Man muss die Ausnahme also sowieso berücksichtigen.

In `get_showid()` wird gar nicht der Fall berücksichtigt, dass die Serie gar nicht gefunden wird. Ausserdem könnte man die Suchschleife abbrechen sobald man die Serie gefunden hat. Ähnliches gilt für `get_episodename()`.

Die XML-Dateien sind IMHO keine so befriedigende Datenstruktur. Die immer wieder zu parsen um linear nach bestimmten Einträgen zu suchen ist ineffizient. Ich würde den Inhalt einmal parsen und so umstellen, dass man die gewünschten Informationen direkter ermitteln kann. Für die Show-ID zum Beispiel ein Wörterbuch, das den klein geschriebenen Seriennamen auf den Seriennamen und die Show-ID abbildet. Dann braucht man auch keine temporären Dateien, sondern kann die Informationen im Speicher behalten.

Den Dateinamen in `generate_new_filename()` sollte man komplett über Zeichenkettenformatierung zusammen setzen. Warum verwendest Du in den vorhandenen Platzhaltern ein '*', wenn der Wert dafür doch jedes mal eine Konstante ist? Die hätte man auch gleich in den Platzhalter schreiben können. Ab Python 2.6 kann man diese Stelle mit der `format()`-Methode *wesentlich* lesbarer gestalten. Ungetestet:

Code: Alles auswählen

def generate_new_filename(episodename):
    return (
        '{showname} - {absolute_number:03d} - {season:02d}x{episode:02d}'
        ' - {episodename}{extension}'
    ).format(**episodename)
Die Vorlage könnte man auch am Anfang des Programms als Konstante definieren. Das gilt auch für einige andere Zeichenketten, wie zum Beispiel die TV-Rage-URLs. Wenn sich da einmal etwas ändern sollte, müsste man nicht das ganze Programm absuchen.

Bei der Funktion fällt mir auf, dass Du an mehrere Funktionen hast, die vom Namen her suggerieren sie würden einen bestimmten Wert ermitteln und zurück geben, die aber in Wirklichkeit ein übergebenes Wörterbuch verändern. Das sollte man IMHO dahingehend ändern dass die Funktionen wirklich nur den Wert ermitteln und das eintragen in einem Wörterbuch vom Aufrufer übernommen wird.

Das Funktionspaar `propose_rename_queue()`/`execute_rename_queue()` hat eine unglückliche Aufteilung zwischen Benutzerinteraktion und Auswertung dieser. Die reine Programmlogik sollte möglichst frei von konkreter Benutzerinteraktion, damit man sich aussuchen kann ob und wie mit dem Benutzer interagiert werden soll. Die Funktion, die das ”batch renaming” letztendlich durchführt sollte selbst nicht prüfen ob sie es wirklich tun soll.

In den beiden Funktionen sollte man die Tupel gleich im Schleifenkopf ”entpacken” und an sprechende Bezeichner binden, statt über die Indizes 0 und 1 innerhalb der Schleife darauf zuzugreifen:

Code: Alles auswählen

    for old, new in queue:
        print os.path.basename(old), '-->'
        print os.path.basename(new)
An vielen Stellen im Programm kann man sich das `dir`-Argument sparen, weil es sowieso schon in der Datenstruktur im Dateinamen steckt.

Ein bisschen Dokumentation wäre nicht schlecht. Was die Funktionen tun und vor allem auch wie die Wörterbücher aufgebaut sind und was die einzelnen Schlüssel/Wert-Paare bedeuten, die dort herum gereicht werden. Nächster Schritt wäre statt Wörterbücher eigene Klassen dafür zu schreiben.

Einige Zeilen sind länger als 80 Zeichen — eine sogar *deutlich* länger. Und der Aufruf von `main()` ist nur zwei Zeichen eingerückt.
Benutzeravatar
waldbaer
User
Beiträge: 4
Registriert: Donnerstag 2. Juni 2011, 17:31

Hallo BlackJack,

vielen Dank für Deinen ausführlichen Beitrag. Werde Deine Verbesserungsvorschläge dieses Wochenende mal durch gehen und Anfang der kommenden Woche eine überarbeitete Version vorstellen.

Gruß waldbaer
Benutzeravatar
waldbaer
User
Beiträge: 4
Registriert: Donnerstag 2. Juni 2011, 17:31

Hallo BlackJack,

habe das Wochenende mal genutzt um mich in das Thema Klassen einzulesen, so wirklich verstanden habe ich es aber in dem Zusammenhang mit meinem Script noch nicht.
Du hattest ja geschrieben, dass es sinnvoll wäre statt der Wörterbücher Klassen zu benutzen.
Heißt das es wäre möglich eine Klasse zu schreiben, die folgendes bietet:

Series[name].Episode[season_nr., episode_nr.].title, Series[name].Episode[season_nr., episode_nr.].airdate, etc.

und dann als Eingabe folgendes zulassen würde:

Series['Game of Thrones'].Episode[1, 1].title = 'Winter is Coming'
Series['Game of Thrones'].Episode[1, 1].airdate = '2011-04-17'

Falls sowas geht könntest Du mir bitte einen Schubs in die richtige Richtung geben, wie die Definition einer solchen Klasse aussehen könnte?

Danke.

Gruß waldbaer
BlackJack

@waldbaer: Das geht nicht mit einer Klasse — so wie es da steht bräuchte man wohl mindestens zwei. *So* würde man das im Quelltext aber dann eher nicht schreiben. Mal abgesehen davon, dass das nach Exemplaren und nicht nach Klassen aussieht und man `series` und `episode` dann konventionell klein schreiben würde, sollte man möglichst nicht in so einer ”Kette” durch die Datenstruktur greifen. Bei den Zuweisungen würde man irgendwo in einer Funktion oder Methode, die eine `Episode` als Argument bekommt, zum Beispiel nur ``episode.title = 'Winter is Coming'`` stehen haben. Beziehungsweise kommt der Titel dann ja aus einer anderen Datenstruktur.

Man könnte eine `Episode`-Klasse schreiben, die mit einem Dateinamen erstellt wird, und den entsprechend zerlegt. Zu Klassen gehören ja nicht nur Datenattribute, sondern auch Methoden, die darauf operieren.

Und die Anfragen an TV-Rage kann man in einer Klasse kapseln, die auch das Caching der Daten übernimmt.

Wo man nun das Setzen der Daten für den neuen Namen vornimmt, ist eine interessante Entwurfsentscheidung. Ausserhalb von der TV-Rage-Anfrageklasse und der Episode, also eine Funktion, die Daten vom einen Objekt abfragt und in das andere einträgt. Oder ob man der TV-Rage-Klasse eine Methode verpasst, die eine Episode übergeben bekommt, und die Daten setzt. Oder ob die Episode eine Methode hat, der ein TV-Rage-Objekt übergeben wird und das sich dann die Daten davon holt.

Und der `Episode`-Klasse könnte man auch die `rename()`-Methode geben.
anogayales
User
Beiträge: 456
Registriert: Mittwoch 15. April 2009, 14:11

Hast du dir schon http://pypi.python.org/pypi/python-tvrage/0.1.4 oder gar http://imdbpy.sourceforge.net/ angeguckt. Bin mit denen auch schon relativ weit gekommen. Wollte nur mal mein Senf dazu geben :)
Benutzeravatar
waldbaer
User
Beiträge: 4
Registriert: Donnerstag 2. Juni 2011, 17:31

@BlackJack: Werde Deine Tipps berücksichtigen, wenn ich versuche mir eine eigene Klasse zu schreiben. Bin seit dem Wochenende nur leider in der Hinsicht zu nichts mehr gekommen.

@anogayales: Danke für den Tipp, auf die Idee danach zu schauen, ob es schon was Fertiges gibt bin ich natürlich nicht gekommen. Hätte ja auch meinem Lernbestreben widersprochen. Werde mir die Sachen aber mal anschauen, vielleicht kann ich ja daraus was lernen wie dort Klassen eingesetzt werden.

Gruß waldbaer
Antworten