After several months thinking about it, and just two requests, I finally decided to publish satyr's code. I decided to use github because I already switched to satyr from hg to git, mainly for testing and understanding it. I think I can live with hg, althought branch management in git seems to be given more thought and a good implementation.

So, without further ado: satyr in github

Remember, it's still a test software, by no means polished or ready for human consumption, and with very low development force. Still, I think it has some nice features, like interchangeable skins and a well defined backend, D-Bus support, quick tag edition, reasonable collection managment, and, thanks to Phonon, almost-gapless playback and things like «stop after playing the current this file» (but not «after any given file» yet).

In Debian Sid it mostly works only with the GStreamer backend; I haven't tried the xine one and I know VLC does not emit a signal needed for queueing the next song, so you have to press «next» after each song. AFAIK this is fixed upstream.

satyr pykde python

Posted Sat 03 Sep 2011 03:01:31 PM CEST Tags: tags/satyr

satyr-0.3.2 "I should install my own food" is out. The Changelog is not very impressive:

  • We can save the list of queued Songs on exit and load it at startup.
  • The queue position is not presented when editing the title of a Song that is queued.
  • Setting the track number works now (was a bug).
  • Fixed a bug in setup.py.

but the last one is somewhat important (Thanks Chipaca). Also, 2 months ago, I made the satyr-0.3.1 "freudian slip" release, when user 'dglent' from http://kde-apps.org/ found a packaging bug. It was not only a bugfixing revision, it also included new features:

  • nowPlaying(), exported via dbus.
  • 'now playing' plugin for irssi.
  • Changes in tags is copied to selected cells (in the same column). This allows 'Massive tag writing'.
  • Fixed "NoneType has no attribute 'row'" bug, but this is just a workaround.
  • Forgot to install complex.ui.

Now go get it!


satyr pykde python

Posted Wed 24 Feb 2010 10:18:56 PM CET Tags: tags/satyr

So far both the default and complex skins in satyr were (ab)using the selection in their respectives views to highlight the current song and, in the case of the complex skin, that meant that you couldn't select cells. This could be (and will be) handy for editing several tags at the same time or for grouping songs in an Album. I decided that this should not longer be this way.

It was really simple: I simple reenabled selection, the extended version (Shift and Control work as you grew to spect). Now the way to highlight the current song is to simply change the background and foreground colors for the cells of that row. How? Simple, extending the QPlayListModel.data() method to provide these properties and using QApplication.palette() to get colors that would stand out from the normal application colors (even the selection) and would still make the text readable:

    def data (self, modelIndex, role):
        if modelIndex.isValid () and modelIndex.row ()<self.aggr.count:
            [...]
            # check the row so we highlight the whole row and not just the cell
            elif role==Qt.BackgroundRole and modelIndex.row ()==self.parent_.modelIndex.row ():
                # highlight the current song
                # must return a QBrush
                data= QVariant (QApplication.palette ().dark ())

            elif role==Qt.ForegroundRole and modelIndex.row ()==self.parent_.modelIndex.row ():
                # highlight the current song
                # must return a QBrush
                data= QVariant (QApplication.palette ().brightText ())
            [...]

And that's almost it... because there's always a but. Whenever the current song changes, there's a little lag between this happens (you can hear the new song being played) and the highlighting updates. This is because I update that self.parent_.modelIndex you see up there, but the QPLM.data() method is not called until the next repaint, which doesn't happen inmediately. So, we must force that repaint. How? Again, it's simple: we already emited the dataChanged() signal in the method setData() when the data could be successfully changed. Now we do the same just so the view updates the highlighting:

    def showSong (self, index):
        [...]
        # FIXME? yes, this could be moved to the model (too many self.appModel's)
        start= self.appModel.index (oldModelIndex.row (), 0)
        end=   self.appModel.index (oldModelIndex.row (), columns)
        self.appModel.dataChanged.emit (start, end)

        start= self.appModel.index (self.modelIndex.row (), 0)
        end=   self.appModel.index (self.modelIndex.row (), columns)
        self.appModel.dataChanged.emit (start, end)
        [...]

satyr pykde python

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

I certainly hope this is the last post in the Phonon-and-badly-encoded/mixed-encodings-filenames saga, but I know is just wishful thinking: as all encoding-related problems they never really dissapear, it's just that you hadn't hit the right wrong stone yet. In any case, I fixed all my later problems wherever they where, and now I can answer this question: how to play files whose filenames are badly encoded and/or have mixed encodings, all this in Phonon?

Right now the answer is: you have to provide a properly encoded QUrl. How, you might ask, can I get one of those? Are they selled in the same odly-looking places where you can buy cigarretes, or even marihuana[1]? The answer, luckly, is way more simple.

Putting together all the code I've been showing about Python, PyQt4/PyKDE4 and Phonon recently, it comes down to this[5]:

# path is a str()
qba= QByteArray (path)
# the exceptions are not needed,
# but is cleaner if you print the outcome of this
qu= QUrl.fromEncoded (qba.toPercentEncoding ("/ "))
# this is needed by the gstreamer backend[3],
# and the xine backend doesn't complain
qu.setScheme ('file')

... and that's it. You can now create a MediaSource with this qu.

There are a couple of ideas that I want to express as conclusion to all this:

  • In an ideal world these things should not happen. But this is one of the lesser problems with this non-ideal world, so bear with it.
  • Paths should not be stored in QStrings, even if they can (and they do) store this kind of pathnames, because if you try to 'encode' its contents (in the Unicode sense; that is, convert it to an encoding like UTF-8[4]) you get farts or barks at best. Yes, you always have constData() but from QString's class reference there is no warranty that this will keep being the case[6].
  • In fact, QString's class reference says at some point: «[one case] where QByteArray is appropriate are when you need to store raw binary data...», and as I already wrote, «[t]his would be the case for paths; you need the bytes».
  • QFile and QDir can only be created from QStrings. I'm not sure if, given all I wrote, that's right.

The good news is that satyr now can play any file that the backends can whatever their filename-as-string-of-bytes is, I'm a little bit happier about it, I got another contribution to KDE and might even have to close a lot of bugs!


satyr pykde python phonon


[1] That question is only legal in Nederlands[2] and very few others cities in the planet.

[2] Actually is not legal. See this wikipedia article.

[3] I might pull up my sleeves again and fix that.

[4] You might have already know this, but if you not: you cannot print Unicode, because Unicode is not and encoding. You have to encode it first. Hence, the toLatin1(), toUtf8() and similar QString methods, and also the inverse from*().

[5] Of course the equivalent C++ code also works, with path being a char *.

[6] And in the case of PyQt4, that method is not even available. But I already globed about it.

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

In my last post I said «The next step is to make my Player class to export its methods via DBus and that's it!». Well, tell you what: is not that easy. If you try to inherit from QObject and dbus.service.Object you get this error:

In [3]: class Klass (QtCore.QObject, dbus.service.Object): pass
<span class="createlink">TypeError</span>: Error when calling the metaclass bases
    metaclass conflict: the metaclass of a derived class must be a
    (non-strict) subclass of the metaclasses of all its bases

This occurs when both ancestors have their own metaclasses. Unluckily Python doesn't resolve it for you. The answer is to create a intermediate metaclass which inherits from both metaclasses (which we can obtain with type()) and make it the metaclass of our class. In code:

class <span class="createlink">MetaPlayer</span> (type (QObject), type (dbus.service.Object)):
    """Dummy metaclass that allows us to inherit from both QObject and
    d.s.Object"""
    pass

class Player (QObject, dbus.service.Object):
    __metaclass__= <span class="createlink">MetaPlayer</span>
    [...]

Is that it now? Can I go and do my code? Unfortunately no. See this:

qdbus org.kde.satyr
/
/player
Cannot introspect object /player at org.kde.satyr:
org.freedesktop.DBus.Python.KeyError (Traceback (most recent call last):
  File "/usr/lib/pymodules/python2.5/dbus/service.py", line 702, in _message_cb
    retval = candidate_method(self, *args, **keywords)
  File "/usr/lib/pymodules/python2.5/dbus/service.py", line 759, in Introspect
    interfaces = self._dbus_class_table[self.__class__.__module__ + '.' + self.__class__.__name__]
<span class="createlink">KeyError</span>: '__main__.Player'
)

This is the class dbus.service.Object complaining something else. It's getting late here and I'm tired, so I'll continue tomorrow.

dbus python pykde satyr

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

More than two months ago I globed about QStrings and paths. The problem was this: my app accepts paths via command line, which are processed via KCmdLineOptions; which in turn converts everything to QStrings. What I wanted were paths, which are more like QByteArrays, not QStrings (because the latter have internally an unicode representation; more on that later). Including PyQt4 in the equation forced me to resort to QByteArray to get the path as a str instead of using QString.constData() (PyQt4 doesn't export that function). But that's only the beginning of the problem.

Take for instance this situation. I have a music collection that I've been building for years now (more that 10, I think). In the old times of this collection the filenames were encoded in iso-8859-1. Then the future came and converted all my machines to utf-8. But only the software; the filesystems were in one way or another inherited from system to system, from machine to machine. So I ended with a mixture of utf and iso filenames, to the point where I have a file whose filename is in iso, but the directory where it is is in utf. Yes, I know, it is a mess. But if I take any decent media player, I can play the file allright. That's because the filesystem knows nothing of encodings (otherwise it would reject badly encoded filenames).

I just spent last saturday making sure that satyr only stored filepaths in strs, not unicodes or QStrings. It took concentration, but having just a bunch of classes and only 3 or 4 points where the filepaths are managed it wasn't that difficult. Still, it took a day. But then, as I mentioned in that post, Phonon the is not able to play such files... or so I thought.

If you run satyr after executing export PHONON_XINE_DEBUG=1 you'll see a lot of Phonon debug info in the console (not that there is another way to run satyr right now anyways). Among all that info you'll see lines such as these two:

void Phonon::Xine::XineStream::setMrl(const QByteArray&, Phonon::Xine::XineStream::StateForNewMrl) ...
bool Phonon::Xine::XineStream::xineOpen(Phonon::State) xine_open succeeded for m_mrl = ...

If you're sharp enough (I'm not; sandsmark from #phonon had to tell me) you'll note the mention of MRL's. MRL's are xine's URL for media. As any URL, they can (and most of the time must) encode 'strange' characters with the so-called "percent encoding". This means that no matter what encodings the different parts of a filepath is in, I just add file:// at the beginning and then I can safely encode it scaping non-ascii characters to %xx representations... or that's what the theory says. One thing to note is that the file:// part must not be scaped; xine complains that the file does not exist in that case.

Looking for help in Qt's classes one can find QUrl and the already known QByteArray. I can call QByteArray.toPercentEnconding() from my str and feed that to QUrl.fromPercentEncoding() (which strangely returns a QString, which is exactly what we're avoiding) or QUrl.fromEncoded(). But then the first function encodes too much, replacing :// with %3A%2F%2F. No fun.

Ok, let's try creating a QByteArray with only the file:// and then append() the toPercentEncoding() of the path only. It works:

<span class="createlink">PyQt4</span>.QtCore.QByteArray('file://%2Fhome%2Fmdione...%2F%C3%9Altimo%20bondi%20a%20Finisterre%2F07-%20La%20peque%F1a%20novia%20del%20carioca.wav')

But then calling QUrl.fromEncoded() gives:

<span class="createlink">PyQt4</span>.QtCore.QUrl("file://xn--/home/mdione.../ltimo bondi a finisterre/07- la pequea novia del carioca-wkmz60758d.wav")

The URL got somehow puny-encoded, which of course xine doesn't recognize for local files.

Another option is to create an empty QUrl, call setEncodedUrl() with the ParsingMode to QUrl.StrictMode so we avoid 50 lines of code that start here[1] that try to escape everything all over again (and I already had some double-or-even-triple-enconding nightmares parsing RSS/Atom feeds last year, thank you), but we get puny-encoded again (maybe it is 'pwny-encoded'?).

Last resort: backtrack to the point were we created only one QByteArray with the path and call toPercentEncoding(); feed that to the method setEncodedPath() of an empty QUrl. Then we add the last piece calling setScheme('file') and we're ready! Of course we're not:

<span class="createlink">PyQt4</span>.QtCore.QByteArray('file:%2Fhome%2Fmdione...%2F%C3%9Altimo%20bondi%20a%20Finisterre%2F07-%20La%20peque%F1a%20novia%20del%20carioca.wav')

Notice the lack of the two // after file:? xine doesn't like it; hence, I don't either.

Ok, this post got too long. I hope I can resolve this soon, I already spent too much time on it. At least a good part of it was expaining it, so others don't have to suffer the same as I did.

BTW, satyr will shortly be released, whether I fix this bug or not.


satyr pykde phonon


[1] Look at the size of that file! 6k lines to handle URL's! Who would say it was so difficult... Once more I'm remembered of how lucky I am to have this libraries at the tips of my fingers, yay!

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

Update before even publishing: most of the numbers in the initial writing were almost doubled. The problem was that distutils left a build directory when I tried either to install or to package satyr, I don't remember which, so the files found by the find commands below were mostly duplicated. I had to remove the directory and run all the commands again!

I wanted to know some things about satyr's code, in particular some statistics about its lines of code. A first approach:

$ find . -name '*.py' -o -name '*.ui' | xargs wc -l | grep total
  2397 total

Hmm, that's a lot, I don't remember wrinting so many lines. Beh, the comments, let's take them out:

$ find . -name '*.py' -o -name '*.ui' | xargs egrep -v '^#' | wc -l
2136

What about empty lines?:

$ find . -name '*.py' -o -name '*.ui' | xargs egrep -v '^(#.*| *)$' | wc -l
1764

Meeh, I didn't take out all the comment lines, only those lines starting with #, which are mainly the license lines on each source file. I have to also count the comments in the middle of the code:

$ find . -name '*.py' -o -name '*.ui' | xargs egrep -v '^( *#.*| *)$' | wc -l
1475

And how much of those lines are actual code and not from some xml file describing user interface?:

$ find . -name '*.py' | xargs egrep -v '^( *#.*| *)$' | wc -l
1124

How much code means its 3 current skins?:

$ find satyr/skins/ -name '*.py' | xargs egrep -v '^( *#.*| *)$' | wc -l
341

How much in the most complex one?

$ egrep -v '^( *#.*| *)$' satyr/skins/complex.py | wc -l
182

All this numbers tell something: ~300 empty lines means that my code is not very tight. I already knew this: I like to break functions in secuential blocks of code, each one accomplishing a somehow atomic step towards the problem the function tries to solve. Almost 300 comment lines means my code is very well commented, even if a sixth of those comments are BUGs, TODOs, FIXMEs or HINTs:

$ find . -name '*.py' | xargs egrep '^ *# (TODO|BUG|FIXME|HINT)' | wc -l
56

Wow, more than I thought. Now, 1100 lines of actual code for all that satyr accomplishes today (playing almost any existing audio file format, progressive collection scanning, lazy tag reading and also tag writing, 3 skins[4], handle several Collections, searching, random, stop after, saving some state, picking up new songs added to the collection via the filesystem, queuing songs[1], dbus interface[2], handle any encodings in filenames[3]... phew! and some minor features more!) I think is quite impressive.

Of course, doing all that in athousandsomething lines of code would be nearly impossible without PyQt4/Qt4, PyKDE4/KDE (something) 4[7], Tagpy/Taglib and finally Python itself. It's really nice to have such a nice framework to work with, really.


satyr pykde python


[1] No user interface for this yet; shame on me.

[2] ... which toguether with kalarm and qdbus make my alarm clock.

[3] Almost all the support is in Phonon itself.

[4] [5] Less than 800 if we don't count skins.

[5] Yes, I add more footnotes as I readproof the posts :)

[6] I skipped [6] :)

[7] After the rebranding I don't know which is the proper name for the libraries, because I'm writing this post while very much offline, and TheDot does not publish the whole articles via rss, which I hate.

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

I figured several things after the last/first release. One of those is that one can't try to pull a beta of your first releases. Betas are for well stablished pieces of code which are supposed to be rock solid; initial releases not. Another thing I figured out (or actually remembered) is that old saying: release early, release often.

So instead of a 0.1 'official' release, where all the bugs are nailed down in their coffins and everything is as peachy and rock solid as a peachy huge rock (like the Mount Fitz Roy[1], for instance), and only 13 days later than the initial release, we get another messy release: satyr-0.2, codenamed "I love when two branches come together", is out.

This time we got that pharaonic refactoring I mentioned in the last release, which means that skins are very independient from the rest of the app, which is good for skins developers and the core developers, even if those sets are equal and only contain me.

From the user point of view, the complex skin is nicer to see (column widths and headers, OMG!) and it also allows tag editing. Yes, because we have tag editing! Right now the only way to fire the edition is to start typing, which will erase previous data, but don't worry, I plan to nail that soon. At least it's usefull for filling up new tags. I also fixed the bug which prevented this skin to highlight which is being played. Lastly but not leastly, the complex skin has a seek bar, and the code got tons of cleanups.

So, that's it. It's out, go grab it!


satyr pykde python


[1] Right now I would consider satyr just a small peeble in a highway, only noticeable if some huge truck picks it up with its wheels and throws it to your windshield. But I plan to reach at least to be a sizable rock such as that one found near one of the Vikings in Mars.

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

One of the things I had to while developing satyr is building a model for a QListViewer. It should be straighforward from qt's documentation, but I found a couple of things that I would like to put in a post, specially because there doesn't seem to be much models in PyQt4 easily found in the web.

According to its description, a subclass of QAbstractListModel as this one should mostly implement the data() and rowCount() methods, which is true. This example creates a read-only model, so no need to implement setData(), but given the simplicity of data(), it doesn't seem too difficult to do. I also wanted it to react when more Songs were added on the fly[1].

The method data() is the most important one. It is not only used for retrieving the data itself, but also some metadata useful for showing the data, like icons and other stuff. For selecting what the caller wants, it refers a Qt.ItemDataRole. The role for the data itself is Qt.DisplayRole. One of the particularities of this method is that it could be called with any vegetable as input; namely, it can refer to a row that does not exist anymore or for metadata that you don't care about. In those cases you must return an empty QVariant, not None. So, a first implementation is:

def data (self, modelIndex, role):
    if modelIndex.isValid () and modelIndex.row ()<self.count and role==Qt.DisplayRole:
        # songForIndex() returns the Song corresponding to the row
        song= self.songForIndex (modelIndex.row ())
        # formatSong() returns a QString with the data to show
        data= QVariant (self.formatSong (song))
    else:
        data= QVariant ()

    return data

This method, together with a rowCount() that simply returns self.count, is enough for showing data that is already there. Notice that the QModelIndex can be not valid, and in this case we only care about its row because we're a list.

But then I wanted my QListViewer to show songs progresively as they are loaded/scanned[2] and also as they are found as new. But then a problem arises: the view is like a table of only one column. The width of this colunm at the begining is the same width as the QListView itself. But what happens when the string shown is too big? What happens is that it gets chopped. We must inform the view that some of the rows are bigger. That's where the metadata comes into play.

Another possible role is Qt.SizeHintRole. If we return a size instead of an empty QVariant, that size will be used to expand the column as needed, even giving us a scrollbar if it's wider that the view.

Now, we're supposed to show the tags for the Song (that's what formatSong() does if possible; if not, it simply returns the filepath), so this width should be calculated based on the length of the string that represents the song[3]. But if we try to read the tags for all the songs as we load the Collection, we end up with too much disk activity before you can show anything to the user, which is unacceptable[4]. So instead we calculate based on the filepath, which is used for Songs with too few tags anyways. Here's the hacky code:

...
# FIXME: kinda hacky
self.fontMetrics= QFontMetrics (KGlobalSettings.generalFont ())
...
def data (self, modelIndex, role):
    if modelIndex.isValid () and modelIndex.row ()<self.count:
        song= self.songForIndex (modelIndex.row ())

        if role==Qt.DisplayRole:
            data= QVariant (self.formatSong (song))
        elif role==Qt.SizeHintRole:
            # calculate something based on the filepath
            data= QVariant (self.fontMetrics.size (Qt.TextSingleLine, song.filepath))
        else:
            data= QVariant ()
    else:
        data= QVariant ()

    return data

The last point then is reacting to Songs are added on the fly. This is also easy: you tell the views you're about to insert rows, you insert them, tell the views you finished, and then emit dataChanged():

def addSong (self):
    # lastIndex keeps track of the last index used.
    row= self.lastIndex
    self.lastIndex+= 1

    self.beginInsertRows (QModelIndex (), row, row)
    # actually the Song has already been added to the Collection[5]
    # so I don't do anything here,
    # but if you keep your rows in this model you should do something here
    self.endInsertRows ()

    self.count+= 1

    modelIndex= self.index (row, 0)
    self.dataChanged.emit (modelIndex, modelIndex)

Later I'll post any peculiarities I find porting all this stuff to a read/write QTableModel.


satyr pykde python


[1] That's material for another post :)

[2] This feature can be said to be a little too much. Actually, I get a flicker when scanning.

[3] Of course the next step is to use a table view and make a model for it.

[4] Right now the load time for a Collection of ~6.5k songs is quite long as it is.

[5] This is a design decision which is not relevant to this example.

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr

Today I sat down and tried to refactor satyr once more after dinner. This time I was trying to decouple the functionality related to multi-collection playlists from PlayListModel while moving it to the default skin. The idea was to be able to create another skin which used a QTableView, which in turn would be a trampoline for implementing tag editing and writing. But I started to stall a little, and that normally provokes me to defocus, to dezone. When that happens, I go and read some piled up posts in akregator[1].

This time I came around a post by Martin Michlmayr (who I read through Planet Debian) from 10 days ago which talks about lessons learned about free software projects. Actually the post is just a resume of 4 posts from the FreeDOS founder Jim Hall. At some point he writes «releases are important».

Bing! goes my head[2]. satyr is already 3 months, 12 days or 117 revisions old and I hadn't released it yet, even after I promissed to do so almost a month ago! The problem is that I kept adding features (and squashing bugs in Phonon[3]) and completely forgot about releasing. Just one semi-colon before he also writes «initial users of the software should be recruited as developers»... which users? If one doesn't release, one might never have users to recruit!

So instead of the pharaonic refactor I had in mind (an in another branch, blessed be bazaar) I wrote a setup.py script in 15 minutes, massaged a little the files (I had to create a package and modify almost all the files to reflect this), tested a little, and produced a nice triplet of files:

satyr-0.1-beta1.tar.bz2
satyr-0.1-beta1.tar.gz
satyr-0.1-beta1.zip

So there you have it, a realease! Ok, it's a 'beta1', but it's out. Go grab it, test it, complain about bugs, tell us you like it, suggest improvements, whatever! And tell your best friend to use it, even if you don't like it! Where to get it? Why, from the project's download's page, of course!


satyr pykde python


[1] Yes, once upon a time I developed my own feed reader called kReiSSy, but it's implemented in PyKDE3 and I don't plan to port it yet, even if somehow is better for me than akregator. A shame, reallly...

[2] in the same way that a µ-wave oven goes “bing”, not in an “Eureka!” way...

[3] I even fixed the need for the 'file' scheme in the Gstreamer backend.

Posted Wed 27 Jan 2010 11:55:54 PM CET Tags: tags/satyr