/* Copyright © 2023 Philipp Wolfer This file is part of Scotty. Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Scotty is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Scotty. If not, see . */ package scrobblerlog import ( "bufio" "fmt" "os" "sort" "strings" "time" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) type ScrobblerLogBackend struct { filePath string includeSkipped bool append bool file *os.File timezone *time.Location log scrobblerlog.ScrobblerLog } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } func (b *ScrobblerLogBackend) Options() []models.BackendOption { return []models.BackendOption{{ Name: "file-path", Label: i18n.Tr("File path"), Type: models.String, }, { Name: "include-skipped", Label: i18n.Tr("Include skipped listens"), Type: models.Bool, }, { Name: "append", Label: i18n.Tr("Append to file"), Type: models.Bool, Default: "true", }, { Name: "time-zone", Label: i18n.Tr("Specify a time zone for the listen timestamps"), Type: models.String, }} } func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.includeSkipped = config.GetBool("include-skipped", false) b.append = config.GetBool("append", true) timezone := config.GetString("time-zone") if timezone != "" { location, err := time.LoadLocation(timezone) if err != nil { return fmt.Errorf("Invalid time-zone %q: %w", timezone, err) } b.log.FallbackTimezone = location } b.log = scrobblerlog.ScrobblerLog{ TZ: scrobblerlog.TZ_UTC, Client: "Rockbox unknown $Revision$", } return nil } func (b *ScrobblerLogBackend) StartImport() error { flags := os.O_RDWR | os.O_CREATE if !b.append { flags |= os.O_TRUNC } file, err := os.OpenFile(b.filePath, flags, 0666) if err != nil { return err } if b.append { stat, err := file.Stat() if err != nil { file.Close() return err } if stat.Size() == 0 { // Zero length file, treat as a new file b.append = false } else { // Verify existing file is a scrobbler log reader := bufio.NewReader(file) if err = b.log.ReadHeader(reader); err != nil { file.Close() return err } if _, err = file.Seek(0, 2); err != nil { return err } } } if !b.append { if err = b.log.WriteHeader(file); err != nil { file.Close() return err } } b.file = file return nil } func (b *ScrobblerLogBackend) FinishImport() error { return b.file.Close() } func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { defer close(results) file, err := os.Open(b.filePath) if err != nil { progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} return } defer file.Close() err = b.log.Parse(file, b.includeSkipped) if err != nil { progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} return } listens := make(models.ListensList, 0, len(b.log.Records)) client := strings.Split(b.log.Client, " ")[0] for _, record := range b.log.Records { listens = append(listens, recordToListen(record, client)) } sort.Sort(listens.NewerThan(oldestTimestamp)) progress <- models.Progress{Elapsed: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { records := make([]scrobblerlog.Record, len(export.Items)) for i, listen := range export.Items { records[i] = listenToRecord(listen) } lastTimestamp, err := b.log.Append(b.file, records) if err != nil { return importResult, err } importResult.UpdateTimestamp(lastTimestamp) importResult.ImportCount += len(export.Items) progress <- models.Progress{}.FromImportResult(importResult) return importResult, nil } func recordToListen(record scrobblerlog.Record, client string) models.Listen { return models.Listen{ ListenedAt: record.Timestamp, Track: models.Track{ ArtistNames: []string{record.ArtistName}, ReleaseName: record.AlbumName, TrackName: record.TrackName, TrackNumber: record.TrackNumber, Duration: record.Duration, RecordingMBID: record.MusicBrainzRecordingID, AdditionalInfo: models.AdditionalInfo{ "rockbox_rating": record.Rating, "media_player": client, }, }, } } func listenToRecord(listen models.Listen) scrobblerlog.Record { var rating scrobblerlog.Rating rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string) if !ok || rockboxRating == "" { rating = scrobblerlog.RATING_LISTENED } else { rating = scrobblerlog.Rating(rating) } return scrobblerlog.Record{ ArtistName: listen.ArtistName(), AlbumName: listen.ReleaseName, TrackName: listen.TrackName, TrackNumber: listen.TrackNumber, Duration: listen.Duration, Rating: rating, Timestamp: listen.ListenedAt, MusicBrainzRecordingID: listen.RecordingMBID, } }