From 5b04bf8119d8c74cfb6778dbfb52cf1e894cd501 Mon Sep 17 00:00:00 2001 From: spl0k Date: Tue, 25 Jun 2013 22:07:49 +0200 Subject: [PATCH] Playlists! --- api/playlists.py | 127 +++++++++++++++++++++++++++++++++++++++++++++++ api/search.py | 6 +-- db.py | 34 ++++++++++++- scanner.py | 4 ++ web.py | 1 + 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100755 api/playlists.py diff --git a/api/playlists.py b/api/playlists.py new file mode 100755 index 0000000..ca17751 --- /dev/null +++ b/api/playlists.py @@ -0,0 +1,127 @@ +# coding: utf-8 + +from flask import request +from sqlalchemy import or_ +import uuid +from web import app +from db import Playlist, User, Track, session +from . import get_entity + +@app.route('/rest/getPlaylists.view', methods = [ 'GET', 'POST' ]) +def list_playlists(): + query = Playlist.query.filter(or_(Playlist.user_id == request.user.id, Playlist.public == True)) + + username = request.args.get('username') + if username: + if not request.user.admin: + return request.error_formatter(50, 'Restricted to admins') + + query = Playlist.query.join(User).filter(User.name == username) + + return request.formatter({ 'playlists': { 'playlist': [ p.as_subsonic_playlist() for p in query ] } }) + +@app.route('/rest/getPlaylist.view', methods = [ 'GET', 'POST' ]) +def show_playlist(): + status, res = get_entity(request, Playlist) + if not status: + return res + + info = res.as_subsonic_playlist() + info['entry'] = [ t.as_subsonic_child(request.user) for t in res.tracks ] + return request.formatter({ 'playlist': info }) + +@app.route('/rest/createPlaylist.view', methods = [ 'GET', 'POST' ]) +def create_playlist(): + # Only(?) method where the android client uses form data rather than GET params + playlist_id, name = map(lambda x: request.args.get(x) or request.form.get(x), [ 'playlistId', 'name' ]) + # songId actually doesn't seem to be required + songs = request.args.getlist('songId') or request.form.getlist('songId') + try: + playlist_id = uuid.UUID(playlist_id) + songs = set(map(uuid.UUID, songs)) + except: + return request.error_formatter(0, 'Invalid parameter') + + if playlist_id: + playlist = Playlist.query.get(playlist_id) + if not playlist: + return request.error_formatter(70, 'Unknwon playlist') + + if playlist.user_id != request.user.id and not request.user.admin: + return request.error_formatter(50, "You're not allowed to modify a playlist that isn't yours") + + playlist.tracks = [] + if name: + playlist.name = name + elif name: + playlist = Playlist(user = request.user, name = name) + session.add(playlist) + else: + return request.error_formatter(10, 'Missing playlist id or name') + + for sid in songs: + track = Track.query.get(sid) + if not track: + return request.error_formatter(70, 'Unknown song') + + playlist.tracks.append(track) + + session.commit() + return request.formatter({}) + +@app.route('/rest/deletePlaylist.view', methods = [ 'GET', 'POST' ]) +def delete_playlist(): + status, res = get_entity(request, Playlist) + if not status: + return res + + if res.user_id != request.user.id and not request.user.admin: + return request.error_formatter(50, "You're not allowed to delete a playlist that isn't yours") + + session.delete(res) + session.commit() + return request.formatter({}) + +@app.route('/rest/updatePlaylist.view', methods = [ 'GET', 'POST' ]) +def update_playlist(): + status, res = get_entity(request, Playlist, 'playlistId') + if not status: + return res + + if res.user_id != request.user.id and not request.user.admin: + return request.error_formatter(50, "You're not allowed to delete a playlist that isn't yours") + + playlist = res + name, comment, public = map(request.args.get, [ 'name', 'comment', 'public' ]) + to_add, to_remove = map(request.args.getlist, [ 'songIdToAdd', 'songIndexToRemove' ]) + try: + to_add = set(map(uuid.UUID, to_add)) + to_remove = sorted(set(map(int, to_remove))) + except: + return request.error_formatter(0, 'Invalid parameter') + + if name: + playlist.name = name + if comment: + playlist.comment = comment + if public: + playlist.public = public in (True, 'True', 'true', 1, '1') + + for sid in to_add: + track = Track.query.get(sid) + if not track: + return request.error_formatter(70, 'Unknown song') + if track not in playlist.tracks: + playlist.tracks.append(track) + + offset = 0 + for idx in to_remove: + idx = idx - offset + if idx < 0 or idx >= len(playlist.tracks): + return request.error_formatter(0, 'Index out of range') + playlist.tracks.pop(idx) + offset += 1 + + session.commit() + return request.formatter({}) + diff --git a/api/search.py b/api/search.py index a7f3b3b..0a812dd 100755 --- a/api/search.py +++ b/api/search.py @@ -4,7 +4,7 @@ from flask import request from web import app from db import Folder, Track, Artist, Album -@app.route('/rest/search.view') +@app.route('/rest/search.view', methods = [ 'GET', 'POST' ]) def old_search(): artist, album, title, anyf, count, offset, newer_than = map(request.args.get, [ 'artist', 'album', 'title', 'any', 'count', 'offset', 'newerThan' ]) try: @@ -43,7 +43,7 @@ def old_search(): 'match': [ r.as_subsonic_child(request.user) for r in query.slice(offset, offset + count) ] }}) -@app.route('/rest/search2.view') +@app.route('/rest/search2.view', methods = [ 'GET', 'POST' ]) def new_search(): query, artist_count, artist_offset, album_count, album_offset, song_count, song_offset = map( request.args.get, [ 'query', 'artistCount', 'artistOffset', 'albumCount', 'albumOffset', 'songCount', 'songOffset' ]) @@ -71,7 +71,7 @@ def new_search(): 'song': [ t.as_subsonic_child(request.user) for t in song_query ] }}) -@app.route('/rest/search3.view') +@app.route('/rest/search3.view', methods = [ 'GET', 'POST' ]) def search_id3(): query, artist_count, artist_offset, album_count, album_offset, song_count, song_offset = map( request.args.get, [ 'query', 'artistCount', 'artistOffset', 'albumCount', 'albumOffset', 'songCount', 'songOffset' ]) diff --git a/db.py b/db.py index 7c43ada..279a34c 100755 --- a/db.py +++ b/db.py @@ -2,7 +2,7 @@ import config -from sqlalchemy import create_engine, Column, ForeignKey, func +from sqlalchemy import create_engine, Table, Column, ForeignKey, func from sqlalchemy import Integer, String, Boolean, DateTime from sqlalchemy.orm import scoped_session, sessionmaker, relationship, backref from sqlalchemy.ext.declarative import declarative_base @@ -332,6 +332,38 @@ class ChatMessage(Base): 'message': self.message } +playlist_track_assoc = Table('playlist_track', Base.metadata, + Column('playlist_id', UUID, ForeignKey('playlist.id')), + Column('track_id', UUID, ForeignKey('track.id')) +) + +class Playlist(Base): + __tablename__ = 'playlist' + + id = UUID.gen_id_column() + user_id = Column(UUID, ForeignKey('user.id')) + name = Column(String) + comment = Column(String, nullable = True) + public = Column(Boolean, default = False) + created = Column(DateTime, default = now) + + user = relationship('User') + tracks = relationship('Track', secondary = playlist_track_assoc) + + def as_subsonic_playlist(self): + info = { + 'id': str(self.id), + 'name': self.name, + 'owner': self.user.name, + 'public': self.public, + 'songCount': len(self.tracks), + 'duration': sum(map(lambda t: t.duration, self.tracks)), + 'created': self.created.isoformat() + } + if self.comment: + info['comment'] = self.comment + return info + def init_db(): Base.metadata.create_all(bind = engine) diff --git a/scanner.py b/scanner.py index 17bb82d..9c24773 100755 --- a/scanner.py +++ b/scanner.py @@ -30,6 +30,10 @@ class Scanner: for track in [ t for t in self.__tracks if t.root_folder.id == folder.id and not os.path.exists(t.path) ]: track.album.tracks.remove(track) track.folder.tracks.remove(track) + # As we don't have a track -> playlists relationship, SQLAlchemy doesn't know it has to remove tracks + # from playlists as well, so let's help it + for playlist in db.Playlist.query.filter(db.Playlist.tracks.contains(track)): + playlist.tracks.remove(track) self.__session.delete(track) self.__deleted_tracks += 1 diff --git a/web.py b/web.py index e55b2a8..61a2c6f 100755 --- a/web.py +++ b/web.py @@ -62,4 +62,5 @@ import api.media import api.annotation import api.chat import api.search +import api.playlists