1 # Copyright (c) 2009-2013, 2019-2021 kaliko <kaliko@azylum.org>
3 # This file is part of sima
5 # sima is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
10 # sima is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with sima. If not, see <http://www.gnu.org/licenses/>.
19 """SQlite database library
23 __HIST_DURATION__ = int(30 * 24) # in hours
27 from collections import deque
28 from datetime import (datetime, timedelta)
30 from sima.lib.meta import Artist, Album
31 from sima.lib.track import Track
34 class SimaDBError(Exception):
43 def __init__(self, db_path=None):
44 self._db_path = db_path
46 def get_database_connection(self):
47 """get database reference"""
48 connection = sqlite3.connect(self._db_path, isolation_level=None)
52 connection = self.get_database_connection()
53 info = connection.execute("""SELECT * FROM db_info
54 WHERE name = "DB Version" LIMIT 1;""").fetchone()
61 connection = self.get_database_connection()
63 'CREATE TABLE IF NOT EXISTS db_info'
64 ' (name CHAR(50), value CHAR(50))')
65 connection.execute('''INSERT INTO db_info (name, value) SELECT ?, ?
67 ( SELECT 1 FROM db_info WHERE name = ? )''',
68 ('DB Version', __DB_VERSION__, 'DB Version'))
69 connection.execute( # ARTISTS
70 'CREATE TABLE IF NOT EXISTS artists (id INTEGER PRIMARY KEY, '
71 'name VARCHAR(100), mbid CHAR(36))')
72 connection.execute( # ALBUMS
73 'CREATE TABLE IF NOT EXISTS albums (id INTEGER PRIMARY KEY, '
74 'name VARCHAR(100), mbid CHAR(36))')
75 connection.execute( # ALBUMARTISTS
76 'CREATE TABLE IF NOT EXISTS albumartists (id INTEGER PRIMARY KEY, '
77 'name VARCHAR(100), mbid CHAR(36))')
78 connection.execute( # TRACKS
79 'CREATE TABLE IF NOT EXISTS tracks (id INTEGER PRIMARY KEY, '
80 'title VARCHAR(100), artist INTEGER, '
81 'album INTEGER, albumartist INTEGER, '
82 'file VARCHAR(500), mbid CHAR(36), '
83 'FOREIGN KEY(artist) REFERENCES artists(id), '
84 'FOREIGN KEY(album) REFERENCES albums(id), '
85 'FOREIGN KEY(albumartist) REFERENCES albumartists(id))')
86 connection.execute( # HISTORY
87 'CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, '
88 'last_play TIMESTAMP, track INTEGER, '
89 'FOREIGN KEY(track) REFERENCES tracks(id))')
90 connection.execute( # BLOCKLIST
91 'CREATE TABLE IF NOT EXISTS blocklist (id INTEGER PRIMARY KEY, '
92 'artist INTEGER, album INTEGER, track INTEGER, '
93 'FOREIGN KEY(artist) REFERENCES artists(id), '
94 'FOREIGN KEY(album) REFERENCES albums(id), '
95 'FOREIGN KEY(track) REFERENCES tracks(id))')
96 connection.execute( # Genres (Many-to-many)
97 'CREATE TABLE IF NOT EXISTS genres '
98 '(id INTEGER PRIMARY KEY, name VARCHAR(100))')
99 connection.execute( # Junction Genres Tracks
100 """CREATE TABLE IF NOT EXISTS tracks_genres
101 ( track INTEGER, genre INTEGER,
102 FOREIGN KEY(track) REFERENCES tracks(id)
103 FOREIGN KEY(genre) REFERENCES genres(id))""")
104 # Create cleanup triggers:
105 # DELETE history → Tracks / Tracks_genres tables
106 connection.execute('''
107 CREATE TRIGGER IF NOT EXISTS del_history_cleanup_tracks
108 AFTER DELETE ON history
109 WHEN ((SELECT count(*) FROM history WHERE track=old.track) = 0 AND
110 (SELECT count(*) FROM blocklist WHERE track=old.track) = 0)
112 DELETE FROM tracks WHERE id = old.track;
113 DELETE FROM tracks_genres WHERE track = old.track;
116 # DELETE Tracks_Genres → Genres table
117 connection.execute('''
118 CREATE TRIGGER IF NOT EXISTS del_tracks_genres_cleanup_genres
119 AFTER DELETE ON tracks_genres
120 WHEN ((SELECT count(*) FROM tracks_genres WHERE genre=old.genre) = 0)
122 DELETE FROM genres WHERE id = old.genre;
125 # DELETE Tracks → Artists table
126 connection.execute('''
127 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_artists
128 AFTER DELETE ON tracks
129 WHEN ((SELECT count(*) FROM tracks WHERE artist=old.artist) = 0 AND
130 (SELECT count(*) FROM blocklist WHERE artist=old.artist) = 0)
132 DELETE FROM artists WHERE id = old.artist;
135 # DELETE Tracks → Albums table
136 connection.execute('''
137 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_albums
138 AFTER DELETE ON tracks
139 WHEN ((SELECT count(*) FROM tracks WHERE album=old.album) = 0 AND
140 (SELECT count(*) FROM blocklist WHERE album=old.album) = 0)
142 DELETE FROM albums WHERE id = old.album;
145 # DELETE Tracks → cleanup AlbumArtists table
146 connection.execute('''
147 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_albumartists
148 AFTER DELETE ON tracks
149 WHEN ((SELECT count(*) FROM tracks WHERE albumartist=old.albumartist) = 0)
151 DELETE FROM albumartists WHERE id = old.albumartist;
154 # DELETE blocklist → Tracks table
155 connection.execute('''
156 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_tracks
157 AFTER DELETE ON blocklist
158 WHEN ((SELECT count(*) FROM history WHERE track=old.track) = 0 AND
159 (SELECT count(*) FROM blocklist WHERE track=old.track) = 0)
161 DELETE FROM tracks WHERE id = old.track;
164 # DELETE blocklist → Artists table
165 # The "SELECT count(*) FROM blocklist" is useless,
166 # there can be only one blocklist.artist
167 connection.execute('''
168 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_artists
169 AFTER DELETE ON blocklist
170 WHEN ((SELECT count(*) FROM tracks WHERE artist=old.artist) = 0 AND
171 (SELECT count(*) FROM blocklist WHERE artist=old.artist) = 0)
173 DELETE FROM artists WHERE id = old.artist;
176 # DELETE Tracks → Albums table
177 # The "SELECT count(*) FROM blocklist" is useless,
178 # there can be only one blocklist.album
179 connection.execute('''
180 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_albums
181 AFTER DELETE ON blocklist
182 WHEN ((SELECT count(*) FROM tracks WHERE album=old.album) = 0 AND
183 (SELECT count(*) FROM blocklist WHERE album=old.album) = 0)
185 DELETE FROM albums WHERE id = old.album;
191 connection = self.get_database_connection()
192 rows = connection.execute(
193 "SELECT name FROM sqlite_master WHERE type='table'")
194 for r in rows.fetchall():
195 connection.execute(f'DROP TABLE IF EXISTS {r[0]}')
198 def _remove_blocklist_id(self, blid, with_connection=None):
201 connection = with_connection
203 connection = self.get_database_connection()
204 connection = self.get_database_connection()
205 connection.execute('DELETE FROM blocklist'
206 ' WHERE blocklist.id = ?', (blid,))
208 if not with_connection:
211 def _get_album(self, album, connection):
213 return connection.execute(
214 "SELECT id FROM albums WHERE mbid = ?",
217 return connection.execute(
218 "SELECT id FROM albums WHERE name = ? AND mbid IS NULL",
221 def get_album(self, album, with_connection=None, add=True):
222 """get album information from the database.
223 if not in database insert new entry.
225 :param sima.lib.meta.Album album: album objet
226 :param sqlite3.Connection with_connection: SQLite connection
229 connection = with_connection
231 connection = self.get_database_connection()
232 rows = self._get_album(album, connection)
234 if not with_connection:
238 if not with_connection:
242 "INSERT INTO albums (name, mbid) VALUES (?, ?)",
243 (album.name, album.mbid))
245 rows = self._get_album(album, connection)
247 if not with_connection:
250 if not with_connection:
254 def _get_albumartist(self, artist, connection):
256 return connection.execute(
257 "SELECT id FROM albumartists WHERE mbid = ?",
260 return connection.execute(
261 "SELECT id FROM albumartists WHERE name = ? AND mbid IS NULL",
264 def get_albumartist(self, artist, with_connection=None, add=True):
265 """get albumartist information from the database.
266 if not in database insert new entry.
268 :param sima.lib.meta.Artist artist: artist
269 :param sqlite3.Connection with_connection: SQLite connection
272 connection = with_connection
274 connection = self.get_database_connection()
275 rows = self._get_albumartist(artist, connection)
277 if not with_connection:
281 if not with_connection:
285 "INSERT INTO albumartists (name, mbid) VALUES (?, ?)",
286 (artist.name, artist.mbid))
288 rows = self._get_albumartist(artist, connection)
290 if not with_connection:
293 if not with_connection:
296 def _get_artist(self, artist, connection):
298 return connection.execute(
299 "SELECT id FROM artists WHERE mbid = ?",
302 return connection.execute(
303 "SELECT id FROM artists WHERE name = ? AND mbid IS NULL", (artist.name,))
305 def get_artist(self, artist, with_connection=None, add=True):
306 """get artist information from the database.
307 if not in database insert new entry.
309 :param sima.lib.meta.Artist artist: artist
310 :param sqlite3.Connection with_connection: SQLite connection
313 connection = with_connection
315 connection = self.get_database_connection()
316 rows = self._get_artist(artist, connection)
318 if not with_connection:
322 if not with_connection:
326 "INSERT INTO artists (name, mbid) VALUES (?, ?)",
327 (artist.name, artist.mbid))
329 rows = self._get_artist(artist, connection)
331 if not with_connection:
334 if not with_connection:
337 def get_genre(self, genre, with_connection=None, add=True):
338 """get genre from the database.
339 if not in database insert new entry.
341 :param str genre: genre as a string
342 :param sqlite3.Connection with_connection: SQLite connection
345 connection = with_connection
347 connection = self.get_database_connection()
348 rows = connection.execute(
349 "SELECT id FROM genres WHERE name = ?", (genre,))
351 if not with_connection:
355 if not with_connection:
359 "INSERT INTO genres (name) VALUES (?)", (genre,))
361 rows = connection.execute(
362 "SELECT id FROM genres WHERE name = ?", (genre,))
364 if not with_connection:
368 def get_track(self, track, with_connection=None, add=True):
369 """Get a track id from Tracks table, add if not existing,
370 :param sima.lib.track.Track track: track to use
371 :param bool add: add non existing track to database"""
373 raise SimaDBError('Got a track with no file attribute: %r' % track)
375 connection = with_connection
377 connection = self.get_database_connection()
378 rows = connection.execute(
379 "SELECT * FROM tracks WHERE file = ?", (track.file,))
381 if not with_connection:
384 if not add: # Not adding non existing track
385 if not with_connection:
388 # Get an artist record or None
390 art = Artist(name=track.artist, mbid=track.musicbrainz_artistid)
391 art_id = self.get_artist(art, with_connection=connection)
394 # Get an albumartist record or None
395 if track.albumartist:
396 albart = Artist(name=track.albumartist,
397 mbid=track.musicbrainz_albumartistid)
398 albart_id = self.get_albumartist(albart, with_connection=connection)
401 # Get an album record or None
403 alb = Album(name=track.album, mbid=track.musicbrainz_albumid)
404 alb_id = self.get_album(alb, with_connection=connection)
408 """INSERT INTO tracks (artist, albumartist, album, title, mbid, file)
409 VALUES (?, ?, ?, ?, ?, ?)""",
410 (art_id, albart_id, alb_id, track.title, track.musicbrainz_trackid,
413 # Add track id to junction tables
414 self._add_tracks_genres(track, connection)
415 rows = connection.execute(
416 "SELECT id FROM tracks WHERE file = ?", (track.file,))
418 if not with_connection:
421 if not with_connection:
425 def _add_tracks_genres(self, track, connection):
428 rows = connection.execute(
429 "SELECT id FROM tracks WHERE file = ?", (track.file,))
430 trk_id = rows.fetchone()[0]
431 for genre in track.genres:
433 gen_id = self.get_genre(genre)
434 connection.execute("""INSERT INTO tracks_genres (track, genre)
435 VALUES (?, ?)""", (trk_id, gen_id))
437 def add_history(self, track, date=None):
438 """Record last play date of track (ie. not a real exhautive play history).
439 :param track sima.lib.track.Track: track to add to history"""
441 date = datetime.now()
442 connection = self.get_database_connection()
443 track_id = self.get_track(track, with_connection=connection)
444 rows = connection.execute("SELECT * FROM history WHERE track = ? ",
446 if not rows.fetchone():
447 connection.execute("INSERT INTO history (track) VALUES (?)",
449 connection.execute("UPDATE history SET last_play = ? "
450 " WHERE track = ?", (date, track_id,))
454 def purge_history(self, duration=__HIST_DURATION__):
455 """Remove old entries in history
456 :param duration int: Purge history record older than duration in hours
457 (defaults to __HIST_DURATION__)"""
458 connection = self.get_database_connection()
459 connection.execute("DELETE FROM history WHERE last_play"
460 " < datetime('now', '-%i hours')" % duration)
461 connection.execute('VACUUM')
465 def fetch_albums_history(self, needle=None, duration=__HIST_DURATION__):
467 :param sima.lib.meta.Artist needle: When specified, returns albums history for this artist.
469 date = datetime.utcnow() - timedelta(hours=duration)
470 connection = self.get_database_connection()
471 connection.row_factory = sqlite3.Row
472 rows = connection.execute("""
473 SELECT albums.name AS name,
475 artists.name as artist,
476 artists.mbid as artist_mbib
478 JOIN tracks ON history.track = tracks.id
479 LEFT OUTER JOIN albums ON tracks.album = albums.id
480 LEFT OUTER JOIN artists ON tracks.artist = artists.id
481 WHERE history.last_play > ? AND albums.name NOT NULL AND artists.name NOT NULL
482 ORDER BY history.last_play DESC""", (date.isoformat(' '),))
486 artist = Artist(name=vals.pop('artist'),
487 mbid=vals.pop('artist_mbib'))
491 album = Album(**vals, artist=artist)
492 if hist and hist[-1] == album:
493 # remove consecutive dupes
499 def fetch_artists_history(self, needle=None, duration=__HIST_DURATION__):
500 """Returns a list of Artist objects
501 :param sima.lib.meta.Artist|sima.lib.meta.MetaContainer needle: When specified, returns history for this artist, it's actually testing the artist presence in history.
502 :param sima.lib.meta.MetaContainer needle: When specified, returns history for these artists only
504 date = datetime.utcnow() - timedelta(hours=duration)
505 connection = self.get_database_connection()
506 connection.row_factory = sqlite3.Row
507 rows = connection.execute("""
508 SELECT artists.name AS name,
511 JOIN tracks ON history.track = tracks.id
512 LEFT OUTER JOIN artists ON tracks.artist = artists.id
513 WHERE history.last_play > ? AND artists.name NOT NULL
514 ORDER BY history.last_play DESC""", (date.isoformat(' '),))
515 last = deque(maxlen=1)
518 artist = Artist(**row)
519 if last and last[0] == artist: # remove consecutive dupes
522 if needle and isinstance(needle, (Artist, str)):
524 hist.append(artist) # No need to go further
527 elif needle and getattr(needle, '__contains__'):
529 hist.append(artist) # No need to go further
535 def fetch_genres_history(self, duration=__HIST_DURATION__, limit=20):
536 date = datetime.utcnow() - timedelta(hours=duration)
537 connection = self.get_database_connection()
538 rows = connection.execute("""
539 SELECT genres.name, artists.name
541 JOIN tracks ON history.track = tracks.id
542 LEFT OUTER JOIN tracks_genres ON tracks_genres.track = tracks.id
543 LEFT OUTER JOIN artists ON tracks.artist = artists.id
544 LEFT OUTER JOIN genres ON genres.id = tracks_genres.genre
545 WHERE history.last_play > ? AND genres.name NOT NULL
546 ORDER BY history.last_play DESC
547 """, (date.isoformat(' '),))
551 if len({g[0] for g in genres}) >= limit:
556 def fetch_history(self, artist=None, duration=__HIST_DURATION__):
557 """Fetches tracks history, more recent first
558 :param sima.lib.meta.Artist artist: limit history to this artist
559 :param int duration: How long ago to fetch history from
561 date = datetime.utcnow() - timedelta(hours=duration)
562 connection = self.get_database_connection()
563 connection.row_factory = sqlite3.Row
565 SELECT tracks.title, tracks.file, artists.name AS artist,
566 albumartists.name AS albumartist,
567 artists.mbid as musicbrainz_artistid,
568 albums.name AS album,
569 albums.mbid AS musicbrainz_albumid,
570 tracks.mbid as musicbrainz_trackid
572 JOIN tracks ON history.track = tracks.id
573 LEFT OUTER JOIN artists ON tracks.artist = artists.id
574 LEFT OUTER JOIN albumartists ON tracks.albumartist = albumartists.id
575 LEFT OUTER JOIN albums ON tracks.album = albums.id
576 WHERE history.last_play > ?
580 rows = connection.execute(sql+"""
582 ORDER BY history.last_play DESC""",
583 (date.isoformat(' '), artist.mbid))
585 rows = connection.execute(sql+"""
587 ORDER BY history.last_play DESC""",
588 (date.isoformat(' '), artist.name))
590 rows = connection.execute(sql+'ORDER BY history.last_play DESC',
591 (date.isoformat(' '),))
594 hist.append(Track(**row))
598 def get_bl_track(self, track, with_connection=None, add=True):
599 """Add a track to blocklist
600 :param sima.lib.track.Track track: Track object to add to blocklist
601 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
602 :param bool add: Default is to add a new record, set to False to fetch associated record"""
604 connection = with_connection
606 connection = self.get_database_connection()
607 track_id = self.get_track(track, with_connection=connection, add=add)
608 rows = connection.execute(
609 "SELECT id FROM blocklist WHERE track = ?", (track_id,))
610 if not rows.fetchone():
612 if not with_connection:
615 connection.execute('INSERT INTO blocklist (track) VALUES (?)',
618 rows = connection.execute(
619 "SELECT id FROM blocklist WHERE track = ?", (track_id,))
620 bl = rows.fetchone()[0]
621 if not with_connection:
625 def get_bl_album(self, album, with_connection=None, add=True):
626 """Add an album to blocklist
627 :param sima.lib.meta.Album: Album object to add to blocklist
628 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
629 :param bool add: Default is to add a new record, set to False to fetch associated record"""
631 connection = with_connection
633 connection = self.get_database_connection()
634 album_id = self.get_album(album, with_connection=connection, add=add)
635 rows = connection.execute(
636 "SELECT id FROM blocklist WHERE album = ?", (album_id,))
637 if not rows.fetchone():
639 if not with_connection:
642 connection.execute('INSERT INTO blocklist (album) VALUES (?)',
645 rows = connection.execute(
646 "SELECT id FROM blocklist WHERE album = ?", (album_id,))
647 bl = rows.fetchone()[0]
648 if not with_connection:
652 def get_bl_artist(self, artist, with_connection=None, add=True):
653 """Add an artist to blocklist
654 :param sima.lib.meta.Artist: Artist object to add to blocklist
655 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
656 :param bool add: Default is to add a new record, set to False to fetch associated record"""
658 connection = with_connection
660 connection = self.get_database_connection()
661 artist_id = self.get_artist(artist, with_connection=connection, add=add)
662 rows = connection.execute(
663 "SELECT id FROM blocklist WHERE artist = ?", (artist_id,))
664 if not rows.fetchone():
667 connection.execute('INSERT INTO blocklist (artist) VALUES (?)',
670 rows = connection.execute(
671 "SELECT id FROM blocklist WHERE artist = ?", (artist_id,))
672 bl = rows.fetchone()[0]
673 if not with_connection:
678 connection = self.get_database_connection()
679 connection.row_factory = sqlite3.Row
680 rows = connection.execute("""SELECT artists.name AS artist,
681 artists.mbid AS musicbrainz_artist,
682 albums.name AS album,
683 albums.mbid AS musicbrainz_album,
684 tracks.title AS title,
685 tracks.mbid AS musicbrainz_title,
689 LEFT OUTER JOIN artists ON blocklist.artist = artists.id
690 LEFT OUTER JOIN albums ON blocklist.album = albums.id
691 LEFT OUTER JOIN tracks ON blocklist.track = tracks.id""")
692 res = [dict(row) for row in rows.fetchall()]
696 def delete_bl(self, track=None, album=None, artist=None):
697 if not (track or album or artist):
699 connection = self.get_database_connection()
702 blid = self.get_bl_track(track, with_connection=connection)
704 blid = self.get_bl_album(album, with_connection=connection)
706 blid = self.get_bl_artist(artist, with_connection=connection)
709 self._remove_blocklist_id(blid, with_connection=connection)
714 # vim: ai ts=4 sw=4 sts=4 expandtab fileencoding=utf8