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
24 #: Default history duration for both request and purge in hours
25 __HIST_DURATION__ = int(30 * 24)
29 from collections import deque
30 from datetime import (datetime, timedelta)
31 from datetime import timezone
34 from sima.lib.meta import Artist, Album
35 from sima.lib.track import Track
38 class SimaDBError(Exception):
47 def __init__(self, db_path=None):
48 self._db_path = db_path
50 def get_database_connection(self):
51 """get database reference"""
52 connection = sqlite3.connect(self._db_path, isolation_level=None)
56 connection = self.get_database_connection()
57 info = connection.execute("""SELECT * FROM db_info
58 WHERE name = "DB Version" LIMIT 1;""").fetchone()
65 connection = self.get_database_connection()
67 'CREATE TABLE IF NOT EXISTS db_info'
68 ' (name CHAR(50), value CHAR(50))')
69 connection.execute('''INSERT INTO db_info (name, value) SELECT ?, ?
71 ( SELECT 1 FROM db_info WHERE name = ? )''',
72 ('DB Version', __DB_VERSION__, 'DB Version'))
73 connection.execute( # ARTISTS
74 'CREATE TABLE IF NOT EXISTS artists (id INTEGER PRIMARY KEY, '
75 'name VARCHAR(100), mbid CHAR(36))')
76 connection.execute( # ALBUMS
77 'CREATE TABLE IF NOT EXISTS albums (id INTEGER PRIMARY KEY, '
78 'name VARCHAR(100), mbid CHAR(36))')
79 connection.execute( # ALBUMARTISTS
80 'CREATE TABLE IF NOT EXISTS albumartists (id INTEGER PRIMARY KEY, '
81 'name VARCHAR(100), mbid CHAR(36))')
82 connection.execute( # TRACKS
83 'CREATE TABLE IF NOT EXISTS tracks (id INTEGER PRIMARY KEY, '
84 'title VARCHAR(100), artist INTEGER, '
85 'album INTEGER, albumartist INTEGER, '
86 'file VARCHAR(500), mbid CHAR(36), '
87 'FOREIGN KEY(artist) REFERENCES artists(id), '
88 'FOREIGN KEY(album) REFERENCES albums(id), '
89 'FOREIGN KEY(albumartist) REFERENCES albumartists(id))')
90 connection.execute( # HISTORY
91 'CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, '
92 'last_play TIMESTAMP, track INTEGER, '
93 'FOREIGN KEY(track) REFERENCES tracks(id))')
94 connection.execute( # BLOCKLIST
95 'CREATE TABLE IF NOT EXISTS blocklist (id INTEGER PRIMARY KEY, '
96 'artist INTEGER, album INTEGER, track INTEGER, '
97 'FOREIGN KEY(artist) REFERENCES artists(id), '
98 'FOREIGN KEY(album) REFERENCES albums(id), '
99 'FOREIGN KEY(track) REFERENCES tracks(id))')
100 connection.execute( # Genres (Many-to-many)
101 'CREATE TABLE IF NOT EXISTS genres '
102 '(id INTEGER PRIMARY KEY, name VARCHAR(100))')
103 connection.execute( # Junction Genres Tracks
104 """CREATE TABLE IF NOT EXISTS tracks_genres
105 ( track INTEGER, genre INTEGER,
106 FOREIGN KEY(track) REFERENCES tracks(id)
107 FOREIGN KEY(genre) REFERENCES genres(id))""")
108 # Create cleanup triggers:
109 # DELETE history → Tracks / Tracks_genres tables
110 connection.execute('''
111 CREATE TRIGGER IF NOT EXISTS del_history_cleanup_tracks
112 AFTER DELETE ON history
113 WHEN ((SELECT count(*) FROM history WHERE track=old.track) = 0 AND
114 (SELECT count(*) FROM blocklist WHERE track=old.track) = 0)
116 DELETE FROM tracks WHERE id = old.track;
117 DELETE FROM tracks_genres WHERE track = old.track;
120 # DELETE Tracks_Genres → Genres table
121 connection.execute('''
122 CREATE TRIGGER IF NOT EXISTS del_tracks_genres_cleanup_genres
123 AFTER DELETE ON tracks_genres
124 WHEN ((SELECT count(*) FROM tracks_genres WHERE genre=old.genre) = 0)
126 DELETE FROM genres WHERE id = old.genre;
129 # DELETE Tracks → Artists table
130 connection.execute('''
131 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_artists
132 AFTER DELETE ON tracks
133 WHEN ((SELECT count(*) FROM tracks WHERE artist=old.artist) = 0 AND
134 (SELECT count(*) FROM blocklist WHERE artist=old.artist) = 0)
136 DELETE FROM artists WHERE id = old.artist;
139 # DELETE Tracks → Albums table
140 connection.execute('''
141 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_albums
142 AFTER DELETE ON tracks
143 WHEN ((SELECT count(*) FROM tracks WHERE album=old.album) = 0 AND
144 (SELECT count(*) FROM blocklist WHERE album=old.album) = 0)
146 DELETE FROM albums WHERE id = old.album;
149 # DELETE Tracks → cleanup AlbumArtists table
150 connection.execute('''
151 CREATE TRIGGER IF NOT EXISTS del_tracks_cleanup_albumartists
152 AFTER DELETE ON tracks
153 WHEN ((SELECT count(*) FROM tracks WHERE albumartist=old.albumartist) = 0)
155 DELETE FROM albumartists WHERE id = old.albumartist;
158 # DELETE blocklist → Tracks table
159 connection.execute('''
160 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_tracks
161 AFTER DELETE ON blocklist
162 WHEN ((SELECT count(*) FROM history WHERE track=old.track) = 0 AND
163 (SELECT count(*) FROM blocklist WHERE track=old.track) = 0)
165 DELETE FROM tracks WHERE id = old.track;
168 # DELETE blocklist → Artists table
169 # The "SELECT count(*) FROM blocklist" is useless,
170 # there can be only one blocklist.artist
171 connection.execute('''
172 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_artists
173 AFTER DELETE ON blocklist
174 WHEN ((SELECT count(*) FROM tracks WHERE artist=old.artist) = 0 AND
175 (SELECT count(*) FROM blocklist WHERE artist=old.artist) = 0)
177 DELETE FROM artists WHERE id = old.artist;
180 # DELETE Tracks → Albums table
181 # The "SELECT count(*) FROM blocklist" is useless,
182 # there can be only one blocklist.album
183 connection.execute('''
184 CREATE TRIGGER IF NOT EXISTS del_blocklist_cleanup_albums
185 AFTER DELETE ON blocklist
186 WHEN ((SELECT count(*) FROM tracks WHERE album=old.album) = 0 AND
187 (SELECT count(*) FROM blocklist WHERE album=old.album) = 0)
189 DELETE FROM albums WHERE id = old.album;
195 connection = self.get_database_connection()
196 rows = connection.execute(
197 "SELECT name FROM sqlite_master WHERE type='table'")
198 for r in rows.fetchall():
199 connection.execute(f'DROP TABLE IF EXISTS {r[0]}')
202 def _remove_blocklist_id(self, blid, with_connection=None):
203 """Remove a blocklist id"""
205 connection = with_connection
207 connection = self.get_database_connection()
208 connection = self.get_database_connection()
209 connection.execute('DELETE FROM blocklist'
210 ' WHERE blocklist.id = ?', (blid,))
212 if not with_connection:
215 def _get_album(self, album, connection):
217 return connection.execute(
218 "SELECT id FROM albums WHERE mbid = ?",
221 return connection.execute(
222 "SELECT id FROM albums WHERE name = ? AND mbid IS NULL",
225 def get_album(self, album, with_connection=None, add=True):
226 """get album information from the database.
227 if not in database insert new entry.
229 :param sima.lib.meta.Album album: album objet
230 :param sqlite3.Connection with_connection: SQLite connection
233 connection = with_connection
235 connection = self.get_database_connection()
236 rows = self._get_album(album, connection)
238 if not with_connection:
242 if not with_connection:
246 "INSERT INTO albums (name, mbid) VALUES (?, ?)",
247 (album.name, album.mbid))
249 rows = self._get_album(album, connection)
251 if not with_connection:
254 if not with_connection:
258 def _get_albumartist(self, artist, connection):
260 return connection.execute(
261 "SELECT id FROM albumartists WHERE mbid = ?",
264 return connection.execute(
265 "SELECT id FROM albumartists WHERE name = ? AND mbid IS NULL",
268 def get_albumartist(self, artist, with_connection=None, add=True):
269 """get albumartist information from the database.
270 if not in database insert new entry.
272 :param sima.lib.meta.Artist artist: artist
273 :param sqlite3.Connection with_connection: SQLite connection
276 connection = with_connection
278 connection = self.get_database_connection()
279 rows = self._get_albumartist(artist, connection)
281 if not with_connection:
285 if not with_connection:
289 "INSERT INTO albumartists (name, mbid) VALUES (?, ?)",
290 (artist.name, artist.mbid))
292 rows = self._get_albumartist(artist, connection)
294 if not with_connection:
297 if not with_connection:
300 def _get_artist(self, artist, connection):
302 return connection.execute(
303 "SELECT id FROM artists WHERE mbid = ?",
306 return connection.execute(
307 "SELECT id FROM artists WHERE name = ? AND mbid IS NULL", (artist.name,))
309 def get_artist(self, artist, with_connection=None, add=True):
310 """get artist information from the database.
311 if not in database insert new entry.
313 :param sima.lib.meta.Artist artist: artist
314 :param sqlite3.Connection with_connection: SQLite connection
317 connection = with_connection
319 connection = self.get_database_connection()
320 rows = self._get_artist(artist, connection)
322 if not with_connection:
326 if not with_connection:
330 "INSERT INTO artists (name, mbid) VALUES (?, ?)",
331 (artist.name, artist.mbid))
333 rows = self._get_artist(artist, connection)
335 if not with_connection:
338 if not with_connection:
341 def get_genre(self, genre, with_connection=None, add=True):
342 """get genre from the database.
343 if not in database insert new entry.
345 :param str genre: genre as a string
346 :param sqlite3.Connection with_connection: SQLite connection
349 connection = with_connection
351 connection = self.get_database_connection()
352 rows = connection.execute(
353 "SELECT id FROM genres WHERE name = ?", (genre,))
355 if not with_connection:
359 if not with_connection:
363 "INSERT INTO genres (name) VALUES (?)", (genre,))
365 rows = connection.execute(
366 "SELECT id FROM genres WHERE name = ?", (genre,))
368 if not with_connection:
372 def get_track(self, track, with_connection=None, add=True):
373 """Get a track id from Tracks table, add if not existing,
375 :param sima.lib.track.Track track: track to use
376 :param bool add: add non existing track to database"""
378 raise SimaDBError('Got a track with no file attribute: %r' % track)
380 connection = with_connection
382 connection = self.get_database_connection()
383 rows = connection.execute(
384 "SELECT * FROM tracks WHERE file = ?", (track.file,))
386 if not with_connection:
389 if not add: # Not adding non existing track
390 if not with_connection:
393 # Get an artist record or None
395 art = Artist(name=track.artist, mbid=track.musicbrainz_artistid)
396 art_id = self.get_artist(art, with_connection=connection)
399 # Get an albumartist record or None
400 if track.albumartist:
401 albart = Artist(name=track.albumartist,
402 mbid=track.musicbrainz_albumartistid)
403 albart_id = self.get_albumartist(albart, with_connection=connection)
406 # Get an album record or None
408 alb = Album(name=track.album, mbid=track.musicbrainz_albumid)
409 alb_id = self.get_album(alb, with_connection=connection)
413 """INSERT INTO tracks (artist, albumartist, album, title, mbid, file)
414 VALUES (?, ?, ?, ?, ?, ?)""",
415 (art_id, albart_id, alb_id, track.title, track.musicbrainz_trackid,
418 # Add track id to junction tables
419 self._add_tracks_genres(track, connection)
420 rows = connection.execute(
421 "SELECT id FROM tracks WHERE file = ?", (track.file,))
423 if not with_connection:
426 if not with_connection:
430 def _add_tracks_genres(self, track, connection):
433 rows = connection.execute(
434 "SELECT id FROM tracks WHERE file = ?", (track.file,))
435 trk_id = rows.fetchone()[0]
436 for genre in track.genres:
438 gen_id = self.get_genre(genre)
439 connection.execute("""INSERT INTO tracks_genres (track, genre)
440 VALUES (?, ?)""", (trk_id, gen_id))
442 def add_history(self, track, date=None):
443 """Record last play date of track (ie. not a real play history).
445 :param sima.lib.track.Track track: track to add to history
446 :param datetime.datetime date: UTC datetime object (use "datetime.now(timezone.utc)" is not set)"""
448 date = datetime.now(timezone.utc)
449 connection = self.get_database_connection()
450 track_id = self.get_track(track, with_connection=connection)
451 rows = connection.execute("SELECT * FROM history WHERE track = ? ",
453 if not rows.fetchone():
454 connection.execute("INSERT INTO history (track) VALUES (?)",
456 connection.execute("UPDATE history SET last_play = ? "
457 " WHERE track = ?", (date, track_id,))
461 def purge_history(self, duration=__HIST_DURATION__):
462 """Remove old entries in history
464 :param int duration: Purge history record older than duration in hours"""
465 connection = self.get_database_connection()
466 connection.execute("DELETE FROM history WHERE last_play"
467 " < datetime('now', '-%i hours')" % duration)
468 connection.execute('VACUUM')
472 def fetch_albums_history(self, needle=None, duration=__HIST_DURATION__):
474 :param sima.lib.meta.Artist needle: When specified, returns albums history for this artist.
475 :param int duration: How long ago to fetch history from (in hours)
477 date = datetime.now(timezone.utc) - timedelta(hours=duration)
478 connection = self.get_database_connection()
479 connection.row_factory = sqlite3.Row
480 rows = connection.execute("""
481 SELECT albums.name AS name,
483 artists.name as artist,
484 artists.mbid as artist_mbib,
485 albumartists.name as albumartist,
486 albumartists.mbid as albumartist_mbib
488 JOIN tracks ON history.track = tracks.id
489 LEFT OUTER JOIN albums ON tracks.album = albums.id
490 LEFT OUTER JOIN artists ON tracks.artist = artists.id
491 LEFT OUTER JOIN albumartists ON tracks.albumartist = albumartists.id
492 WHERE history.last_play > ? AND albums.name NOT NULL AND artists.name NOT NULL
493 ORDER BY history.last_play DESC""", (date.isoformat(' '),))
497 if needle: # Here use artist instead of albumartist
498 if needle != Artist(name=vals.get('artist'),
499 mbid=vals.get('artist_mbib')):
501 # Use albumartist / MBIDs if possible to build album artist
502 if not vals.get('albumartist'):
503 vals['albumartist'] = vals.get('artist')
504 if not vals.get('albumartist_mbib'):
505 vals['albumartist_mbib'] = vals.get('artist_mbib')
506 artist = Artist(name=vals.get('albumartist'),
507 mbid=vals.pop('albumartist_mbib'))
508 album = Album(**vals, Artist=artist)
509 if hist and hist[-1] == album:
510 # remove consecutive dupes
516 def fetch_artists_history(self, needle=None, duration=__HIST_DURATION__):
517 """Returns a list of Artist objects
519 :param sima.lib.meta.MetaContainer needle: When specified, returns history for these artists only
520 :param int duration: How long ago to fetch history from (in hours)
521 :type needle: sima.lib.meta.Artist or sima.lib.meta.MetaContainer
523 date = datetime.now(timezone.utc) - timedelta(hours=duration)
524 connection = self.get_database_connection()
525 connection.row_factory = sqlite3.Row
526 rows = connection.execute("""
527 SELECT artists.name AS name,
530 JOIN tracks ON history.track = tracks.id
531 LEFT OUTER JOIN artists ON tracks.artist = artists.id
532 WHERE history.last_play > ? AND artists.name NOT NULL
533 ORDER BY history.last_play DESC""", (date.isoformat(' '),))
534 last = deque(maxlen=1)
537 artist = Artist(**row)
538 if last and last[0] == artist: # remove consecutive dupes
541 if needle and isinstance(needle, (Artist, str)):
543 hist.append(artist) # No need to go further
546 elif needle and getattr(needle, '__contains__'):
548 hist.append(artist) # No need to go further
554 def fetch_genres_history(self, duration=__HIST_DURATION__, limit=20):
555 """Returns genre history
557 :param int duration: How long ago to fetch history from (in hours)
558 :param int limit: number of genre to fetch
560 date = datetime.now(timezone.utc) - timedelta(hours=duration)
561 connection = self.get_database_connection()
562 rows = connection.execute("""
563 SELECT genres.name, artists.name
565 JOIN tracks ON history.track = tracks.id
566 LEFT OUTER JOIN tracks_genres ON tracks_genres.track = tracks.id
567 LEFT OUTER JOIN artists ON tracks.artist = artists.id
568 LEFT OUTER JOIN genres ON genres.id = tracks_genres.genre
569 WHERE history.last_play > ? AND genres.name NOT NULL
570 ORDER BY history.last_play DESC
571 """, (date.isoformat(' '),))
575 if len({g[0] for g in genres}) >= limit:
580 def fetch_history(self, artist=None, duration=__HIST_DURATION__):
581 """Fetches tracks history, more recent first
583 :param sima.lib.meta.Artist artist: limit history to this artist
584 :param int duration: How long ago to fetch history from (in hours)
586 date = datetime.now(timezone.utc) - timedelta(hours=duration)
587 connection = self.get_database_connection()
588 connection.row_factory = sqlite3.Row
590 SELECT tracks.title, tracks.file, artists.name AS artist,
591 albumartists.name AS albumartist,
592 artists.mbid as musicbrainz_artistid,
593 albums.name AS album,
594 albums.mbid AS musicbrainz_albumid,
595 tracks.mbid as musicbrainz_trackid
597 JOIN tracks ON history.track = tracks.id
598 LEFT OUTER JOIN artists ON tracks.artist = artists.id
599 LEFT OUTER JOIN albumartists ON tracks.albumartist = albumartists.id
600 LEFT OUTER JOIN albums ON tracks.album = albums.id
601 WHERE history.last_play > ?
605 rows = connection.execute(sql+"""
607 ORDER BY history.last_play DESC""",
608 (date.isoformat(' '), artist.mbid))
610 rows = connection.execute(sql+"""
612 ORDER BY history.last_play DESC""",
613 (date.isoformat(' '), artist.name))
615 rows = connection.execute(sql+'ORDER BY history.last_play DESC',
616 (date.isoformat(' '),))
619 hist.append(Track(**row))
623 def get_bl_track(self, track, with_connection=None, add=True):
624 """Add a track to blocklist
626 :param sima.lib.track.Track track: Track object to add to blocklist
627 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
628 :param bool add: Default is to add a new record, set to False to fetch associated record"""
630 connection = with_connection
632 connection = self.get_database_connection()
633 track_id = self.get_track(track, with_connection=connection, add=add)
634 rows = connection.execute(
635 "SELECT id FROM blocklist WHERE track = ?", (track_id,))
636 if not rows.fetchone():
638 if not with_connection:
641 connection.execute('INSERT INTO blocklist (track) VALUES (?)',
644 rows = connection.execute(
645 "SELECT id FROM blocklist WHERE track = ?", (track_id,))
646 bl = rows.fetchone()[0]
647 if not with_connection:
651 def get_bl_album(self, album, with_connection=None, add=True):
652 """Add an album to blocklist
654 :param sima.lib.meta.Album: Album 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 album_id = self.get_album(album, with_connection=connection, add=add)
662 rows = connection.execute(
663 "SELECT id FROM blocklist WHERE album = ?", (album_id,))
664 if not rows.fetchone():
666 if not with_connection:
669 connection.execute('INSERT INTO blocklist (album) VALUES (?)',
672 rows = connection.execute(
673 "SELECT id FROM blocklist WHERE album = ?", (album_id,))
674 bl = rows.fetchone()[0]
675 if not with_connection:
679 def get_bl_artist(self, artist, with_connection=None, add=True):
680 """Add an artist to blocklist
682 :param sima.lib.meta.Artist: Artist object to add to blocklist
683 :param sqlite3.Connection with_connection: sqlite3.Connection to reuse, else create a new one
684 :param bool add: Default is to add a new record, set to False to fetch associated record"""
686 connection = with_connection
688 connection = self.get_database_connection()
689 artist_id = self.get_artist(artist, with_connection=connection, add=add)
690 rows = connection.execute(
691 "SELECT id FROM blocklist WHERE artist = ?", (artist_id,))
692 if not rows.fetchone():
695 connection.execute('INSERT INTO blocklist (artist) VALUES (?)',
698 rows = connection.execute(
699 "SELECT id FROM blocklist WHERE artist = ?", (artist_id,))
700 bl = rows.fetchone()[0]
701 if not with_connection:
706 connection = self.get_database_connection()
707 connection.row_factory = sqlite3.Row
708 rows = connection.execute("""SELECT artists.name AS artist,
709 artists.mbid AS musicbrainz_artist,
710 albums.name AS album,
711 albums.mbid AS musicbrainz_album,
712 tracks.title AS title,
713 tracks.mbid AS musicbrainz_title,
717 LEFT OUTER JOIN artists ON blocklist.artist = artists.id
718 LEFT OUTER JOIN albums ON blocklist.album = albums.id
719 LEFT OUTER JOIN tracks ON blocklist.track = tracks.id""")
720 res = [dict(row) for row in rows.fetchall()]
724 def delete_bl(self, track=None, album=None, artist=None):
725 if not (track or album or artist):
727 connection = self.get_database_connection()
730 blid = self.get_bl_track(track, with_connection=connection)
732 blid = self.get_bl_album(album, with_connection=connection)
734 blid = self.get_bl_artist(artist, with_connection=connection)
737 self._remove_blocklist_id(blid, with_connection=connection)
742 # vim: ai ts=4 sw=4 sts=4 expandtab fileencoding=utf8