Update code
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
from argparse import ArgumentParser
|
||||
from os.path import join
|
||||
from . import _
|
||||
from .audio import generate_audio_report
|
||||
from .audio import prepare_audio_map, generate_audio_report
|
||||
from .cache import IdRelativeCache
|
||||
from .config import Config
|
||||
from .db import PlaybackReportingDb, LibraryDb
|
||||
from .db import PlaybackReportingDb, LibraryDb, JellyfinDb
|
||||
|
||||
|
||||
p = ArgumentParser(prog="jellyfinstats")
|
||||
@@ -13,9 +14,20 @@ p.add_argument("--library-db", help=_("The path to library.db"))
|
||||
p.add_argument("--jellyfin-data-dir", help=_("The path to jellyfin data directory.")) # noqa: E501
|
||||
p.add_argument("--output-dir", help=_("The directory for output files."))
|
||||
p.add_argument("--ask-page-size", help=_("Specify maximum items to display in one page."), type=int) # noqa: E501
|
||||
p.add_argument("--jellyfin-db", help=_("The path to jellyfin.db"))
|
||||
arg = p.parse_intermixed_args()
|
||||
cfg = Config(arg.config, arg)
|
||||
with PlaybackReportingDb(cfg.playback_reporting_db) as pdb:
|
||||
with LibraryDb(cfg.library_db) as ldb:
|
||||
with IdRelativeCache(cfg.output_dir) as icache:
|
||||
generate_audio_report(pdb, ldb, icache, cfg)
|
||||
with JellyfinDb(cfg.jellyfin_db) as jdb:
|
||||
re = prepare_audio_map(pdb, ldb, icache, cfg)
|
||||
users = pdb.get_users('Audio')
|
||||
for u in users:
|
||||
userid = u['UserId']
|
||||
user = jdb.get_user(userid)
|
||||
username = user['Username'] if user else userid
|
||||
output = join(cfg.output_dir, 'audio', username)
|
||||
maxDate = u['MaxDate']
|
||||
minDate = u['MinDate']
|
||||
generate_audio_report(pdb, re[0], re[1], output, userid)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from . import _
|
||||
from .cache import IdRelativeCache
|
||||
from .config import Config
|
||||
from .csv import CSVFile
|
||||
from .db import PlaybackReportingDb, LibraryDb
|
||||
from .utils import ask_choice
|
||||
from .utils import ask_choice, parse_time
|
||||
from datetime import datetime
|
||||
from re import compile
|
||||
from os import makedirs
|
||||
from os.path import join
|
||||
|
||||
|
||||
ITEMNAME_PATTERN = compile(r'(?P<album_artist>.*) - (?P<track>.*) \((?P<album>.*)\)') # noqa: E501
|
||||
@@ -108,40 +112,41 @@ class AudioSelector:
|
||||
return self.re
|
||||
|
||||
|
||||
def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb,
|
||||
icache: IdRelativeCache, cfg: Config):
|
||||
def prepare_audio_map(pdb: PlaybackReportingDb, ldb: LibraryDb,
|
||||
icache: IdRelativeCache, cfg: Config):
|
||||
offset = 0
|
||||
data = pdb.get_activitys(offset, itemType='Audio')
|
||||
count = 0
|
||||
re = None
|
||||
itemMap = {}
|
||||
itemList = {}
|
||||
rowMap = {}
|
||||
while len(data) > 0:
|
||||
for d in data:
|
||||
itemId = d['ItemId']
|
||||
rowid = d['rowid']
|
||||
item = ldb.get_item(itemId)
|
||||
if not item:
|
||||
re = icache.get(itemId)
|
||||
if re:
|
||||
if re and isinstance(re, str):
|
||||
if re == 'no_track':
|
||||
if itemId in itemMap:
|
||||
rowMap[rowid] = itemId
|
||||
else:
|
||||
itemMap[itemId] = d
|
||||
rowMap[rowid] = itemId
|
||||
continue
|
||||
re = None
|
||||
elif re:
|
||||
item = ldb.get_item(re['id'])
|
||||
if item and isinstance(item, str):
|
||||
if item == 'no_track':
|
||||
if itemId in itemMap:
|
||||
itemList[itemId].append(d)
|
||||
else:
|
||||
itemMap[itemId] = d
|
||||
itemList[itemId] = [d]
|
||||
continue
|
||||
elif item and item['PresentationUniqueKey'] in itemMap:
|
||||
itemList[item['PresentationUniqueKey']].append(d)
|
||||
if item and item['PresentationUniqueKey'] in itemMap:
|
||||
rowMap[rowid] = item['PresentationUniqueKey']
|
||||
continue
|
||||
else:
|
||||
if itemId in itemMap:
|
||||
itemList[itemId].append(d)
|
||||
rowMap[rowid] = itemId
|
||||
continue
|
||||
else:
|
||||
if itemId in itemMap:
|
||||
itemList[itemId].append(d)
|
||||
rowMap[rowid] = itemId
|
||||
continue
|
||||
if not item:
|
||||
itemName = d['ItemName']
|
||||
@@ -173,12 +178,52 @@ def generate_audio_report(pdb: PlaybackReportingDb, ldb: LibraryDb,
|
||||
if item:
|
||||
itemId = item['PresentationUniqueKey']
|
||||
itemMap[itemId] = item
|
||||
itemList[itemId] = [d]
|
||||
rowMap[rowid] = itemId
|
||||
else:
|
||||
print(d)
|
||||
itemMap[itemId] = d
|
||||
itemList[itemId] = [d]
|
||||
count += 1
|
||||
rowMap[rowid] = itemId
|
||||
offset += len(data)
|
||||
data = pdb.get_activitys(offset, itemType='Audio')
|
||||
print('Count', count)
|
||||
return itemMap, rowMap
|
||||
|
||||
|
||||
def generate_audio_report(pdb: PlaybackReportingDb, itemMap, rowMap,
|
||||
output: str, userId: str = None,
|
||||
startTime: float = None, endTime: float = None):
|
||||
makedirs(output, exist_ok=True)
|
||||
offset = 0
|
||||
data = pdb.get_activitys(offset, itemType='Audio', userId=userId,
|
||||
startTime=startTime, endTime=endTime)
|
||||
with CSVFile(join(output, "history.csv")) as his:
|
||||
his.write(_("Id"), _("Date"), _("Time"), _("Name"), _("Artists"), _("Album"), _("Album artists"), _("Original item id"), _("Item id"), _("Play Duration"), _("Record content"), _("Client name"), _("Device name"), _("Playback method")) # noqa: E501
|
||||
while len(data) > 0:
|
||||
for i in data:
|
||||
rowid = i['rowid']
|
||||
itemId = rowMap[rowid]
|
||||
created = datetime.fromtimestamp(parse_time(i['DateCreated']),
|
||||
None)
|
||||
date = created.strftime("%Y-%m-%d")
|
||||
time = created.strftime("%H:%M:%S.%f")
|
||||
item = itemMap[itemId]
|
||||
name = ''
|
||||
artists = ''
|
||||
album = ''
|
||||
album_artists = ''
|
||||
original_item_id = i['ItemId']
|
||||
play_duration = i['PlayDuration']
|
||||
if 'type' in item:
|
||||
name = item['Name']
|
||||
artists = item['Artists']
|
||||
album = item['Album']
|
||||
album_artists = item['AlbumArtists']
|
||||
else:
|
||||
it = ITEMNAME_PATTERN.match(item['ItemName']).groupdict()
|
||||
name = it['track']
|
||||
if it['album'] != 'Not Known':
|
||||
album = it['album']
|
||||
if it['album_artist'] != "Not Known":
|
||||
album_artists = it['album_artist']
|
||||
his.write(rowid, date, time, name, artists, album, album_artists, original_item_id, itemId, play_duration, i['ItemName'], i['ClientName'], i['DeviceName'], i['PlaybackMethod']) # noqa: E501
|
||||
offset += len(data)
|
||||
data = pdb.get_activitys(offset, itemType='Audio', userId=userId,
|
||||
startTime=startTime, endTime=endTime)
|
||||
|
||||
@@ -57,6 +57,17 @@ class Config:
|
||||
if 'jellyfin_data_dir' in self._data and self._data['jellyfin_data_dir']: # noqa: E501
|
||||
return self._data['jellyfin_data_dir']
|
||||
|
||||
@cached_property
|
||||
def jellyfin_db(self) -> str:
|
||||
if self._args and self._args.jellyfin_db:
|
||||
return self._args.jellyfin_db
|
||||
if 'jellyfin_db' in self._data and self._data['jellyfin_db']:
|
||||
return self._data['jellyfin_db']
|
||||
d = self.jellyfin_data_dir
|
||||
if d:
|
||||
return join(d, "jellyfin.db")
|
||||
raise ValueError(_('%s not set.') % ('jellyfin_db'))
|
||||
|
||||
@cached_property
|
||||
def output_dir(self) -> str:
|
||||
if self._args and self._args.output_dir:
|
||||
|
||||
109
jellyfinstats/csv.py
Normal file
109
jellyfinstats/csv.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from enum import Enum
|
||||
from typing import IO, List
|
||||
|
||||
|
||||
UTF8_BOM = b'\xef\xbb\xbf'
|
||||
|
||||
|
||||
def escapeField(s: str) -> str:
|
||||
if s.find('\r\n') > -1 or s.find(',') > -1 or s.find('"') > -1:
|
||||
return '"' + s.replace('"', '""') + '"'
|
||||
else:
|
||||
return s
|
||||
|
||||
|
||||
def readField(f: IO[str]) -> List[str]:
|
||||
t = f.readline()
|
||||
le = len(t)
|
||||
if le == 0:
|
||||
return None
|
||||
i = 0
|
||||
r = []
|
||||
s = ''
|
||||
e = False
|
||||
a = False
|
||||
while i < le:
|
||||
n = t[i]
|
||||
if n == '"' and s == '':
|
||||
e = True
|
||||
elif n == '"' and e:
|
||||
e = False
|
||||
a = True
|
||||
elif n == '"' and a:
|
||||
e = True
|
||||
a = False
|
||||
s += '"'
|
||||
elif not e and n == ',':
|
||||
r.append(s)
|
||||
s = ''
|
||||
a = False
|
||||
else:
|
||||
a = False
|
||||
s += n
|
||||
i += 1
|
||||
if e and i == le:
|
||||
t += f.readline()
|
||||
le = len(t)
|
||||
if s != '':
|
||||
r.append(s)
|
||||
if r[-1][-1] == '\n':
|
||||
r[-1] = r[-1][:-1]
|
||||
return r
|
||||
|
||||
|
||||
def writeField(f: IO[bytes], *k):
|
||||
a = False
|
||||
for i in k:
|
||||
if a:
|
||||
f.write(b',')
|
||||
else:
|
||||
a = True
|
||||
if isinstance(i, bool):
|
||||
i = str(int(i))
|
||||
elif isinstance(i, Enum):
|
||||
i = str(i.value)
|
||||
elif not isinstance(i, str):
|
||||
i = str(i)
|
||||
f.write(escapeField(i).encode())
|
||||
f.write(b'\r\n')
|
||||
|
||||
|
||||
class OpenMode(Enum):
|
||||
Read = 1
|
||||
Write = 2
|
||||
|
||||
|
||||
class CSVFile:
|
||||
def __init__(self, path: str, mode: OpenMode = OpenMode.Write):
|
||||
mod = 'rb' if mode == OpenMode.Read else 'wb'
|
||||
self._mode = mode
|
||||
self._f = open(path, mod)
|
||||
if mode == OpenMode.Read:
|
||||
bom = self._f.read(3)
|
||||
if bom != UTF8_BOM:
|
||||
self._f.seek(0)
|
||||
else:
|
||||
self._f.write(UTF8_BOM)
|
||||
self._closed = False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, tp, val, trace):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
if self._closed:
|
||||
return
|
||||
self._f.close()
|
||||
self._closed = True
|
||||
|
||||
def read(self):
|
||||
if self._mode != OpenMode.Read:
|
||||
raise ValueError("Stream is not readable")
|
||||
return readField(self._f)
|
||||
|
||||
def write(self, *k):
|
||||
if self._mode != OpenMode.Write:
|
||||
raise ValueError("Stream is not writable")
|
||||
writeField(self._f, *k)
|
||||
@@ -1,4 +1,5 @@
|
||||
import sqlite3
|
||||
from .utils import convert_uid, format_time
|
||||
|
||||
|
||||
class PlaybackReportingDb:
|
||||
@@ -19,15 +20,38 @@ class PlaybackReportingDb:
|
||||
self._closed = True
|
||||
|
||||
def get_activitys(self, offset: int = 0, limit: int = 100,
|
||||
itemType: str = None):
|
||||
itemType: str = None, userId: str = None,
|
||||
startTime: float = None, endTime: float = None):
|
||||
where_sqls = []
|
||||
where_sql = ''
|
||||
args = []
|
||||
if itemType is not None:
|
||||
where_sqls.append('ItemType = ?')
|
||||
args.append(itemType)
|
||||
if userId is not None:
|
||||
where_sqls.append('UserId = ?')
|
||||
args.append(userId)
|
||||
if startTime is not None:
|
||||
where_sqls.append('DateCreated >= ?')
|
||||
args.append(format_time(startTime))
|
||||
if endTime is not None:
|
||||
where_sqls.append('DateCreated <= ?')
|
||||
args.append(format_time(endTime))
|
||||
if len(where_sqls):
|
||||
where_sql = ' WHERE ' + " AND ".join(where_sqls)
|
||||
args.append(limit)
|
||||
args.append(offset)
|
||||
cur = self._db.execute(f"SELECT ROWID, * FROM PlaybackActivity{where_sql} LIMIT ? OFFSET ?;", args) # noqa: E501
|
||||
cur.row_factory = sqlite3.Row
|
||||
return [dict(i) for i in cur.fetchall()]
|
||||
|
||||
def get_users(self, itemType: str = None):
|
||||
where_sql = ''
|
||||
args = []
|
||||
if itemType is not None:
|
||||
where_sql = ' WHERE ItemType = ?'
|
||||
args.append(itemType)
|
||||
args.append(limit)
|
||||
args.append(offset)
|
||||
cur = self._db.execute(f"SELECT * FROM PlaybackActivity{where_sql} LIMIT ? OFFSET ?;", args) # noqa: E501
|
||||
cur = self._db.execute(f"SELECT UserId, min(DateCreated) AS MinDate, max(DateCreated) AS MaxDate FROM PlaybackActivity{where_sql} GROUP BY UserId;", args) # noqa: E501
|
||||
cur.row_factory = sqlite3.Row
|
||||
return [dict(i) for i in cur.fetchall()]
|
||||
|
||||
@@ -72,3 +96,29 @@ class LibraryDb:
|
||||
cur = self._db.execute(f"SELECT * FROM TypedBaseItems WHERE type = ?{where_sql};", args) # noqa: E501
|
||||
cur.row_factory = sqlite3.Row
|
||||
return [dict(i) for i in cur.fetchall()]
|
||||
|
||||
|
||||
class JellyfinDb:
|
||||
def __init__(self, fn: str):
|
||||
self._db = sqlite3.connect(fn)
|
||||
self._closed = False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, tp, val, trace):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
if self._closed:
|
||||
return
|
||||
self._db.close()
|
||||
self._closed = True
|
||||
|
||||
def get_user(self, userId: str):
|
||||
if len(userId) == 32:
|
||||
userId = convert_uid(userId)
|
||||
cur = self._db.execute("SELECT * FROM Users WHERE Id = ?;", [userId])
|
||||
cur.row_factory = sqlite3.Row
|
||||
re = cur.fetchone()
|
||||
return dict(re) if re is not None else None
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from math import ceil
|
||||
from datetime import datetime, timezone
|
||||
from re import compile
|
||||
from . import _
|
||||
from .config import Config
|
||||
|
||||
|
||||
DATETIME_RE = compile(r'(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}).(\d{0,6})') # noqa: E501
|
||||
|
||||
|
||||
def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "),
|
||||
fn=None, extra=None):
|
||||
if extra:
|
||||
@@ -66,3 +71,19 @@ def ask_choice(cfg: Config, choices: list, prompt=_("Please choose: "),
|
||||
if index < 0 or index >= count:
|
||||
continue
|
||||
return choices[index]
|
||||
|
||||
|
||||
def format_time(time: float | None = None, tz=timezone.utc) -> str:
|
||||
d = datetime.fromtimestamp(time, tz=tz)
|
||||
return d.strftime('%Y-%m-%d %H:%M:%S.%f')
|
||||
|
||||
|
||||
def parse_time(time: str) -> float:
|
||||
re = DATETIME_RE.match(time)
|
||||
t = datetime(int(re[1]), int(re[2]), int(re[3]), int(re[4]), int(re[5]), int(re[6]), int(re[7].ljust(6, '0')), timezone.utc) # noqa: E501
|
||||
return t.timestamp()
|
||||
|
||||
|
||||
def convert_uid(uid: str) -> str:
|
||||
t = uid.upper()
|
||||
return f"{t[:8]}-{t[8:12]}-{t[12:16]}-{t[16:20]}-{t[20:]}"
|
||||
|
||||
Reference in New Issue
Block a user