diff --git a/jellyfinstats/__main__.py b/jellyfinstats/__main__.py index ce9c910..33ba6f4 100644 --- a/jellyfinstats/__main__.py +++ b/jellyfinstats/__main__.py @@ -30,4 +30,4 @@ with PlaybackReportingDb(cfg.playback_reporting_db) as pdb: output = join(cfg.output_dir, 'audio', username) maxDate = u['MaxDate'] minDate = u['MinDate'] - generate_audio_report(pdb, re[0], re[1], output, userid) + generate_audio_report(pdb, re[0], re[1], re[2], output, userid) diff --git a/jellyfinstats/audio.py b/jellyfinstats/audio.py index 2f57632..ac5fca1 100644 --- a/jellyfinstats/audio.py +++ b/jellyfinstats/audio.py @@ -3,14 +3,17 @@ from .cache import IdRelativeCache from .config import Config from .csv import CSVFile from .db import PlaybackReportingDb, LibraryDb -from .utils import ask_choice, parse_time +from .utils import ask_choice, format_duration, parse_time from datetime import datetime from re import compile from os import makedirs from os.path import join +from math import floor ITEMNAME_PATTERN = compile(r'(?P.*) - (?P.*) \((?P.*)\)') # noqa: E501 +NOT_KNOWN = "Not Known" +TIME_BASE = 10_000_000 def print_item(item): @@ -155,9 +158,9 @@ def prepare_audio_map(pdb: PlaybackReportingDb, ldb: LibraryDb, if re is None: raise ValueError(f"Failed to parse ItemName: {itemName}") re = re.groupdict() - if re['album_artist'] == 'Not Known': + if re['album_artist'] == NOT_KNOWN: re['album_artist'] = None - if re['album'] == 'Not Known': + if re['album'] == NOT_KNOWN: re['album'] = None items = ldb.get_audios(re['track'], re['album']) if len(items) == 1: @@ -184,18 +187,57 @@ def prepare_audio_map(pdb: PlaybackReportingDb, ldb: LibraryDb, rowMap[rowid] = itemId offset += len(data) data = pdb.get_activitys(offset, itemType='Audio') - return itemMap, rowMap + albumMap = {} + for itemId in itemMap: + item = itemMap[itemId] + album = '' + album_artists = '' + if 'type' in item: + album = item['Album'] + album_artists = item['AlbumArtists'] + else: + it = ITEMNAME_PATTERN.match(item['ItemName']).groupdict() + if it['album'] != NOT_KNOWN: + album = it['album'] + if it['album_artist'] != NOT_KNOWN: + album_artists = it['album_artist'] + if album: + if album not in albumMap: + album_artists = album_artists if album_artists else None + items = ldb.get_albums(album, album_artists) + if len(items) == 0: + items = ldb.get_albums(album) + if len(items) == 1: + albumMap[album] = items[0] + elif len(items) > 1: + print(len(items)) + raise NotImplementedError('FIX ME') + else: + data = {'name': album} + if 'type' in item: + data['album_artists'] = item['AlbumArtists'] + data['date'] = item['PremiereDate'] + data['year'] = item['ProductionYear'] + data['publisher'] = item['Studios'] + else: + data['album_artists'] = album_artists + data['date'] = None + data['year'] = None + data['publisher'] = None + albumMap[album] = data + return itemMap, rowMap, albumMap -def generate_audio_report(pdb: PlaybackReportingDb, itemMap, rowMap, +def generate_audio_report(pdb: PlaybackReportingDb, itemMap, rowMap, albumMap, 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) + albumCountMap = {} 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 + his.write(_("Id"), _("Date"), _("Time"), _("Name"), _("Artists"), _("Album"), _("Album artists"), _("Duration"), _("Duration") + _("(seconds)"), _("Original item id"), _("Item id"), _("Play duration"), _("Play duration") + _("(seconds)"), _("Record content"), _("Client name"), _("Device name"), _("Playback method"), _("Play count")) # noqa: E501 while len(data) > 0: for i in data: rowid = i['rowid'] @@ -211,19 +253,66 @@ def generate_audio_report(pdb: PlaybackReportingDb, itemMap, rowMap, album_artists = '' original_item_id = i['ItemId'] play_duration = i['PlayDuration'] + duration = None + play_count = 1 if 'type' in item: name = item['Name'] artists = item['Artists'] album = item['Album'] album_artists = item['AlbumArtists'] + duration = item['RunTimeTicks'] / 10_000_000 + play_count = floor(play_duration / duration) + extrad = play_duration % duration + if extrad > 60 or extrad > duration * 0.95: + play_count += 1 else: it = ITEMNAME_PATTERN.match(item['ItemName']).groupdict() name = it['track'] - if it['album'] != 'Not Known': + if it['album'] != NOT_KNOWN: album = it['album'] - if it['album_artist'] != "Not Known": + 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 + his.write(rowid, date, time, name, artists, album, album_artists, format_duration(duration), duration, original_item_id, itemId, format_duration(play_duration), play_duration, i['ItemName'], i['ClientName'], i['DeviceName'], i['PlaybackMethod'], play_count) # noqa: E501 + if album: + if album in albumCountMap: + tmp = albumCountMap[album] + tmp['count'] += 1 + tmp['play_count'] += play_count + tmp['duration'] += play_duration + else: + albumCountMap[album] = {'count': 1, + 'play_count': play_count, + 'duration': play_duration} offset += len(data) data = pdb.get_activitys(offset, itemType='Audio', userId=userId, startTime=startTime, endTime=endTime) + with CSVFile(join(output, 'album.csv')) as al: + al.write(_("Name"), _("Album artists"), _("Artists"), _("Record count"), _("Play count"), _("Play duration"), _("Play duration") + _("(seconds)"), _("Duration"), _("Duration") + _("(seconds)"), _("Year"), _("Publish date"), _("Publisher"), _("Item id")) # noqa: E501 + for album in albumCountMap: + if album not in albumMap: + continue + item = albumMap[album] + album_artists = '' + artists = '' + count = albumCountMap[album] + duration = None + year = None + publisher = None + itemId = None + if 'type' in item: + album_artists = item['AlbumArtists'] + artists = item['Artists'] + duration = item['RunTimeTicks'] / TIME_BASE + year = item['ProductionYear'] + date = item['PremiereDate'] + publisher = item['Studios'] + itemId = item['PresentationUniqueKey'] + else: + album_artists = item['album_artists'] + year = item['year'] + date = item['date'] + publisher = item['publisher'] + if year and date: + if date.endswith("-01-01 00:00:00"): + date = None + al.write(album, album_artists, artists, count['count'], count['play_count'], format_duration(count['duration']), count['duration'], format_duration(duration), duration, year, date, publisher, itemId) # noqa: E501 diff --git a/jellyfinstats/csv.py b/jellyfinstats/csv.py index d9ff307..afbba9d 100644 --- a/jellyfinstats/csv.py +++ b/jellyfinstats/csv.py @@ -58,7 +58,9 @@ def writeField(f: IO[bytes], *k): f.write(b',') else: a = True - if isinstance(i, bool): + if i is None: + i = '' + elif isinstance(i, bool): i = str(int(i)) elif isinstance(i, Enum): i = str(i.value) diff --git a/jellyfinstats/db.py b/jellyfinstats/db.py index 81b33f5..e59953e 100644 --- a/jellyfinstats/db.py +++ b/jellyfinstats/db.py @@ -97,6 +97,19 @@ class LibraryDb: cur.row_factory = sqlite3.Row return [dict(i) for i in cur.fetchall()] + def get_albums(self, album: str = None, albumArtists: str = None): + args = ['MediaBrowser.Controller.Entities.Audio.MusicAlbum'] + where_sql = '' + if album is not None: + where_sql += ' AND Name = ?' + args.append(album) + if albumArtists is not None: + where_sql += ' AND AlbumArtists = ?' + args.append(albumArtists) + 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): diff --git a/jellyfinstats/language/jellyfinStats.po b/jellyfinstats/language/jellyfinStats.po index 8285e03..9c0b738 100644 --- a/jellyfinstats/language/jellyfinStats.po +++ b/jellyfinstats/language/jellyfinStats.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: jellyfinStats 1.0\n" "Report-Msgid-Bugs-To: root@lifegpc.com\n" -"POT-Creation-Date: 2024-05-15 13:56+0800\n" +"POT-Creation-Date: 2024-05-16 10:47+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,102 +17,190 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: __main__.py:10 +#: __main__.py:20 msgid "The path to config file." msgstr "" -#: __main__.py:11 +#: __main__.py:21 msgid "The path to playback_reporting.db" msgstr "" -#: __main__.py:12 +#: __main__.py:22 msgid "The path to library.db" msgstr "" -#: __main__.py:13 +#: __main__.py:23 msgid "The path to jellyfin data directory." msgstr "" -#: __main__.py:14 +#: __main__.py:24 msgid "The directory for output files." msgstr "" -#: __main__.py:15 +#: __main__.py:25 msgid "Specify maximum items to display in one page." msgstr "" -#: audio.py:15 audio.py:35 +#: __main__.py:26 +msgid "The path to jellyfin.db" +msgstr "" + +#: audio.py:22 audio.py:42 msgid "Album: " msgstr "" -#: audio.py:17 +#: audio.py:24 msgid "Artists: " msgstr "" -#: audio.py:19 audio.py:37 +#: audio.py:26 audio.py:44 msgid "Album artists: " msgstr "" -#: audio.py:33 +#: audio.py:40 msgid "Original item: " msgstr "" -#: audio.py:40 audio.py:77 +#: audio.py:47 audio.py:84 msgid "Please choose audio item:" msgstr "" -#: audio.py:40 audio.py:48 audio.py:74 +#: audio.py:47 audio.py:55 audio.py:81 msgid "No item" msgstr "" -#: audio.py:40 +#: audio.py:47 msgid "Choose other items" msgstr "" -#: audio.py:48 +#: audio.py:55 msgid "Input item id" msgstr "" -#: audio.py:48 +#: audio.py:55 msgid "Input track name" msgstr "" -#: audio.py:48 +#: audio.py:55 msgid "Input album name" msgstr "" -#: audio.py:50 audio.py:76 +#: audio.py:57 audio.py:83 msgid "Choose in given choices" msgstr "" -#: audio.py:51 +#: audio.py:58 msgid "Please choose action: " msgstr "" -#: audio.py:64 +#: audio.py:71 msgid "Please input item id:" msgstr "" -#: audio.py:69 +#: audio.py:76 msgid "Item not found." msgstr "" -#: audio.py:74 +#: audio.py:81 msgid "Back" msgstr "" -#: audio.py:85 +#: audio.py:92 msgid "Items not found." msgstr "" -#: audio.py:89 +#: audio.py:96 msgid "Please input track name:" msgstr "" -#: audio.py:94 +#: audio.py:101 msgid "Please input album name:" msgstr "" +#: audio.py:240 +msgid "Id" +msgstr "" + +#: audio.py:240 +msgid "Date" +msgstr "" + +#: audio.py:240 +msgid "Time" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Name" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Artists" +msgstr "" + +#: audio.py:240 +msgid "Album" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Album artists" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Duration" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "(seconds)" +msgstr "" + +#: audio.py:240 +msgid "Original item id" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Item id" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Play duration" +msgstr "" + +#: audio.py:240 +msgid "Record content" +msgstr "" + +#: audio.py:240 +msgid "Client name" +msgstr "" + +#: audio.py:240 +msgid "Device name" +msgstr "" + +#: audio.py:240 +msgid "Playback method" +msgstr "" + +#: audio.py:240 audio.py:290 +msgid "Play count" +msgstr "" + +#: audio.py:290 +msgid "Record count" +msgstr "" + +#: audio.py:290 +msgid "Year" +msgstr "" + +#: audio.py:290 +msgid "Publish date" +msgstr "" + +#: audio.py:290 +msgid "Publisher" +msgstr "" + #: cache.py:23 msgid "Unsupported version: " msgstr "" @@ -121,32 +209,37 @@ msgstr "" msgid "Failed to load cache." msgstr "" -#: config.py:40 config.py:51 +#: config.py:40 config.py:51 config.py:69 #, python-format msgid "%s not set." msgstr "" -#: utils.py:6 +#: utils.py:11 msgid "Please choose: " msgstr "" -#: utils.py:23 +#: utils.py:28 #, python-format msgid "Page %i/%i" msgstr "" -#: utils.py:31 +#: utils.py:36 msgid "First page" msgstr "" -#: utils.py:33 +#: utils.py:38 msgid "Previous page" msgstr "" -#: utils.py:36 +#: utils.py:41 msgid "Next page" msgstr "" -#: utils.py:38 +#: utils.py:43 msgid "Last page" msgstr "" + +#: utils.py:97 +#, python-format +msgid "%i day" +msgstr "" diff --git a/jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.po b/jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.po index b192f08..2e1e722 100644 --- a/jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.po +++ b/jellyfinstats/language/zh_CN/LC_MESSAGES/jellyfinStats.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: jellyfinStats 1.0\n" "Report-Msgid-Bugs-To: root@lifegpc.com\n" -"POT-Creation-Date: 2024-05-15 13:56+0800\n" +"POT-Creation-Date: 2024-05-16 10:47+0800\n" "PO-Revision-Date: 2024-05-15 10:20+0800\n" "Last-Translator: mhy \n" "Language-Team: Chinese (simplified) \n" @@ -16,102 +16,190 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: __main__.py:10 +#: __main__.py:20 msgid "The path to config file." msgstr "配置文件位置。" -#: __main__.py:11 +#: __main__.py:21 msgid "The path to playback_reporting.db" msgstr "playback_reporting.db 文件位置。" -#: __main__.py:12 +#: __main__.py:22 msgid "The path to library.db" msgstr "library.db 文件位置。" -#: __main__.py:13 +#: __main__.py:23 msgid "The path to jellyfin data directory." msgstr "Jellyfin 数据目录位置。" -#: __main__.py:14 +#: __main__.py:24 msgid "The directory for output files." msgstr "输出目录位置。" -#: __main__.py:15 +#: __main__.py:25 msgid "Specify maximum items to display in one page." msgstr "指定一页可以显示的最大条目数。" -#: audio.py:15 audio.py:35 +#: __main__.py:26 +msgid "The path to jellyfin.db" +msgstr "jellyfin.db 文件位置。" + +#: audio.py:22 audio.py:42 msgid "Album: " msgstr "专辑:" -#: audio.py:17 +#: audio.py:24 msgid "Artists: " msgstr "艺术家:" -#: audio.py:19 audio.py:37 +#: audio.py:26 audio.py:44 msgid "Album artists: " msgstr "专辑艺术家:" -#: audio.py:33 +#: audio.py:40 msgid "Original item: " msgstr "原项目:" -#: audio.py:40 audio.py:77 +#: audio.py:47 audio.py:84 msgid "Please choose audio item:" msgstr "请选择音乐项目:" -#: audio.py:40 audio.py:48 audio.py:74 +#: audio.py:47 audio.py:55 audio.py:81 msgid "No item" msgstr "无对应项目" -#: audio.py:40 +#: audio.py:47 msgid "Choose other items" msgstr "选择其他项目" -#: audio.py:48 +#: audio.py:55 msgid "Input item id" msgstr "输入项目ID" -#: audio.py:48 +#: audio.py:55 msgid "Input track name" msgstr "输入标题" -#: audio.py:48 +#: audio.py:55 msgid "Input album name" msgstr "输入专辑名称" -#: audio.py:50 audio.py:76 +#: audio.py:57 audio.py:83 msgid "Choose in given choices" msgstr "从给出的选项中选择" -#: audio.py:51 +#: audio.py:58 msgid "Please choose action: " msgstr "请选择方式:" -#: audio.py:64 +#: audio.py:71 msgid "Please input item id:" msgstr "请输入项目ID:" -#: audio.py:69 +#: audio.py:76 msgid "Item not found." msgstr "没有找到项目。" -#: audio.py:74 +#: audio.py:81 msgid "Back" msgstr "返回" -#: audio.py:85 +#: audio.py:92 msgid "Items not found." msgstr "没有找到项目。" -#: audio.py:89 +#: audio.py:96 msgid "Please input track name:" msgstr "请输入标题:" -#: audio.py:94 +#: audio.py:101 msgid "Please input album name:" msgstr "请输入专辑名称:" +#: audio.py:240 +msgid "Id" +msgstr "ID" + +#: audio.py:240 +msgid "Date" +msgstr "日期" + +#: audio.py:240 +msgid "Time" +msgstr "时间" + +#: audio.py:240 audio.py:290 +msgid "Name" +msgstr "名称" + +#: audio.py:240 audio.py:290 +msgid "Artists" +msgstr "艺术家" + +#: audio.py:240 +msgid "Album" +msgstr "专辑" + +#: audio.py:240 audio.py:290 +msgid "Album artists" +msgstr "专辑艺术家" + +#: audio.py:240 audio.py:290 +msgid "Duration" +msgstr "时长" + +#: audio.py:240 audio.py:290 +msgid "(seconds)" +msgstr "(秒)" + +#: audio.py:240 +msgid "Original item id" +msgstr "原项目ID" + +#: audio.py:240 audio.py:290 +msgid "Item id" +msgstr "项目ID" + +#: audio.py:240 audio.py:290 +msgid "Play duration" +msgstr "播放时长" + +#: audio.py:240 +msgid "Record content" +msgstr "记录内容" + +#: audio.py:240 +msgid "Client name" +msgstr "客户端名称" + +#: audio.py:240 +msgid "Device name" +msgstr "设备名称" + +#: audio.py:240 +msgid "Playback method" +msgstr "播放方式" + +#: audio.py:240 audio.py:290 +msgid "Play count" +msgstr "播放次数" + +#: audio.py:290 +msgid "Record count" +msgstr "记录次数" + +#: audio.py:290 +msgid "Year" +msgstr "年份" + +#: audio.py:290 +msgid "Publish date" +msgstr "发布日期" + +#: audio.py:290 +msgid "Publisher" +msgstr "发布者" + #: cache.py:23 msgid "Unsupported version: " msgstr "不支持的版本:" @@ -120,32 +208,37 @@ msgstr "不支持的版本:" msgid "Failed to load cache." msgstr "加载缓存失败。" -#: config.py:40 config.py:51 +#: config.py:40 config.py:51 config.py:69 #, python-format msgid "%s not set." msgstr "%s 未设置。" -#: utils.py:6 +#: utils.py:11 msgid "Please choose: " msgstr "请选择:" -#: utils.py:23 +#: utils.py:28 #, python-format msgid "Page %i/%i" msgstr "第%i/%i页" -#: utils.py:31 +#: utils.py:36 msgid "First page" msgstr "第一页" -#: utils.py:33 +#: utils.py:38 msgid "Previous page" msgstr "上一页" -#: utils.py:36 +#: utils.py:41 msgid "Next page" msgstr "下一页" -#: utils.py:38 +#: utils.py:43 msgid "Last page" msgstr "最后一页" + +#: utils.py:97 +#, python-format +msgid "%i day" +msgstr "%i 天" diff --git a/jellyfinstats/utils.py b/jellyfinstats/utils.py index 3bd090a..f7e0657 100644 --- a/jellyfinstats/utils.py +++ b/jellyfinstats/utils.py @@ -1,4 +1,4 @@ -from math import ceil +from math import ceil, floor from datetime import datetime, timezone from re import compile from . import _ @@ -87,3 +87,19 @@ def parse_time(time: str) -> float: def convert_uid(uid: str) -> str: t = uid.upper() return f"{t[:8]}-{t[8:12]}-{t[12:16]}-{t[16:20]}-{t[20:]}" + + +def format_duration(duration: float | None) -> str: + if duration is None: + return '' + duration = round(duration) + re = '' + if duration >= 86400: + re += _("%i day") % (floor(duration / 86400)) + " " + duration %= 86400 + if duration >= 3600: + re += str(floor(duration / 3600)).rjust(2, "0") + ":" + duration %= 3600 + min = str(floor(duration / 60)).rjust(2, "0") + sec = str(duration % 60).rjust(2, "0") + return f"{re}{min}:{sec}"