Compare commits

...

14 commits

Author SHA1 Message Date
Philipp Wolfer
1f48abc284
Fixed timestamp displayed after import not being the updated one 2025-05-04 15:18:14 +02:00
Philipp Wolfer
54fffce1d9
Update translation files 2025-05-04 13:31:44 +02:00
Philipp Wolfer
cb6a534fa1 Translated using Weblate (German)
Currently translated at 100.0% (55 of 55 strings)

Co-authored-by: Philipp Wolfer <phw@uploadedlobster.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2025-05-04 11:25:07 +00:00
Philipp Wolfer
05f0e8d172
Change string for aborted progress bar 2025-05-04 13:24:12 +02:00
Philipp Wolfer
a8517ea249
funkwhale: fix progress abort on error 2025-05-04 13:22:51 +02:00
Philipp Wolfer
dfe6773744
Update translations 2025-05-04 13:07:02 +02:00
Philipp Wolfer
aae5123c3d
Show progress bars as aborted on export / import error 2025-05-04 13:06:48 +02:00
Philipp Wolfer
15d939e150
Update changelog 2025-05-04 12:56:50 +02:00
Philipp Wolfer
55ac41b147
If import fails still save the last reported timestamp
This allows continuing a partially failed import run.
2025-05-04 11:53:46 +02:00
Philipp Wolfer
069f0de2ee
Call "FinishImport" even on error
This gives the importer the chance to close connections
and free resources to ensure already imported items are
properly handled.
2025-05-04 11:53:45 +02:00
Philipp Wolfer
3b1adc9f1f
Fix duplicate calls to handle import errors
This fixes the import process hanging on error
2025-05-04 11:53:43 +02:00
Philipp Wolfer
1c3364dad5
Close export results channel in generic implementation
This removes the need for every implementation to handle this case.
2025-05-04 11:53:42 +02:00
Philipp Wolfer
9480c69cbb
Handle wait group for progress bar centrally
This does not need to be exposed and caller only
needs to wait for the Progress instance.
2025-05-04 11:53:35 +02:00
Philipp Wolfer
b3136bde9a
jspf: add MB extension, if it does not exist 2025-05-04 11:52:45 +02:00
21 changed files with 340 additions and 435 deletions

View file

@ -1,7 +1,16 @@
# Scotty Changelog # Scotty Changelog
## 0.6.0 - WIP ## 0.6.0 - WIP
- JSPF: Implemented export as loves and listens - Fix program hanging endlessly if import fails (#11)
- If import fails still store the last successfully imported timestamp
- Show progress bars as aborted on export / import error
- JSPF: implemented export as loves and listens
- JSPF: write track duration
- JSPF: read username and recording MSID
- JSPF: add MusicBrainz playlist extension in append mode, if it does not exist
in the existing JSPF file
- scrobblerlog: fix timezone not being set from config (#6)
- scrobblerlog: fix listen export not considering latest timestamp
## 0.5.2 - 2025-05-01 ## 0.5.2 - 2025-05-01
@ -20,9 +29,9 @@
- ListenBrainz: log missing recording MBID on love import - ListenBrainz: log missing recording MBID on love import
- Subsonic: support OpenSubsonic fields for recording MBID and genres (#5) - Subsonic: support OpenSubsonic fields for recording MBID and genres (#5)
- Subsonic: fixed progress for loves export - Subsonic: fixed progress for loves export
- scrobblerlog: add "time-zone" config option (#6). - scrobblerlog: add "time-zone" config option (#6)
- scrobblerlog: fixed progress for listen export - scrobblerlog: fixed progress for listen export
- scrobblerlog: renamed setting `include-skipped` to `ignore-skipped`. - scrobblerlog: renamed setting `include-skipped` to `ignore-skipped`
Note: 386 builds for Linux are not available with this release due to an Note: 386 builds for Linux are not available with this release due to an
incompatibility with latest version of gorm. incompatibility with latest version of gorm.

View file

@ -88,15 +88,13 @@ func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan
totalDuration := startTime.Sub(oldestTimestamp) totalDuration := startTime.Sub(oldestTimestamp)
defer close(results)
p := models.Progress{Total: int64(totalDuration.Seconds())} p := models.Progress{Total: int64(totalDuration.Seconds())}
out: out:
for { for {
result, err := b.client.UserHistory(offset, perPage) result, err := b.client.UserHistory(offset, perPage)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -155,8 +153,6 @@ func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m
offset := math.MaxInt32 offset := math.MaxInt32
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
p := models.Progress{Total: int64(perPage)} p := models.Progress{Total: int64(perPage)}
var totalCount int var totalCount int
@ -164,7 +160,7 @@ out:
for { for {
result, err := b.client.UserTracks(offset, perPage) result, err := b.client.UserTracks(offset, perPage)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the 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 terms of the GNU General Public License as published by the Free Software
@ -35,8 +35,9 @@ func (p ListensExportProcessor) ExportBackend() models.Backend {
} }
func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
defer close(results)
defer close(progress)
p.Backend.ExportListens(oldestTimestamp, results, progress) p.Backend.ExportListens(oldestTimestamp, results, progress)
close(progress)
} }
type LovesExportProcessor struct { type LovesExportProcessor struct {
@ -48,6 +49,7 @@ func (p LovesExportProcessor) ExportBackend() models.Backend {
} }
func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
defer close(results)
defer close(progress)
p.Backend.ExportLoves(oldestTimestamp, results, progress) p.Backend.ExportLoves(oldestTimestamp, results, progress)
close(progress)
} }

View file

@ -64,8 +64,6 @@ func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results c
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
listens := make(models.ListensList, 0, 2*perPage) listens := make(models.ListensList, 0, 2*perPage)
p := models.Progress{Total: int64(perPage)} p := models.Progress{Total: int64(perPage)}
@ -113,8 +111,6 @@ func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results cha
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
loves := make(models.LovesList, 0, 2*perPage) loves := make(models.LovesList, 0, 2*perPage)
p := models.Progress{Total: int64(perPage)} p := models.Progress{Total: int64(perPage)}
@ -123,7 +119,7 @@ out:
for { for {
result, err := b.client.GetFavoriteTracks(page, perPage) result, err := b.client.GetFavoriteTracks(page, perPage)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -41,7 +41,7 @@ func (p ListensImportProcessor) Process(results chan models.ListensResult, out c
func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { func (p ListensImportProcessor) Import(export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
if export.Error != nil { if export.Error != nil {
return handleError(result, export.Error, progress), export.Error return result, export.Error
} }
if export.Total > 0 { if export.Total > 0 {
@ -51,7 +51,7 @@ func (p ListensImportProcessor) Import(export models.ListensResult, result model
} }
importResult, err := p.Backend.ImportListens(export, result, progress) importResult, err := p.Backend.ImportListens(export, result, progress)
if err != nil { if err != nil {
return handleError(result, err, progress), err return importResult, err
} }
return importResult, nil return importResult, nil
} }
@ -70,7 +70,7 @@ func (p LovesImportProcessor) Process(results chan models.LovesResult, out chan
func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { func (p LovesImportProcessor) Import(export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
if export.Error != nil { if export.Error != nil {
return handleError(result, export.Error, progress), export.Error return result, export.Error
} }
if export.Total > 0 { if export.Total > 0 {
@ -80,7 +80,7 @@ func (p LovesImportProcessor) Import(export models.LovesResult, result models.Im
} }
importResult, err := p.Backend.ImportLoves(export, result, progress) importResult, err := p.Backend.ImportLoves(export, result, progress)
if err != nil { if err != nil {
return handleError(importResult, err, progress), err return importResult, err
} }
return importResult, nil return importResult, nil
} }
@ -89,35 +89,35 @@ func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](
defer close(out) defer close(out)
defer close(progress) defer close(progress)
result := models.ImportResult{} result := models.ImportResult{}
p := models.Progress{}
err := processor.ImportBackend().StartImport() if err := processor.ImportBackend().StartImport(); err != nil {
if err != nil {
out <- handleError(result, err, progress) out <- handleError(result, err, progress)
return return
} }
for exportResult := range results { for exportResult := range results {
importResult, err := processor.Import(exportResult, result, out, progress) importResult, err := processor.Import(exportResult, result, out, progress)
if err != nil {
out <- handleError(result, err, progress)
return
}
result.Update(importResult) result.Update(importResult)
progress <- models.Progress{}.FromImportResult(result) if err != nil {
processor.ImportBackend().FinishImport()
out <- handleError(result, err, progress)
return
}
progress <- p.FromImportResult(result)
} }
err = processor.ImportBackend().FinishImport() if err := processor.ImportBackend().FinishImport(); err != nil {
if err != nil {
out <- handleError(result, err, progress) out <- handleError(result, err, progress)
return return
} }
progress <- models.Progress{}.FromImportResult(result).Complete() progress <- p.FromImportResult(result).Complete()
out <- result out <- result
} }
func handleError(result models.ImportResult, err error, progress chan models.Progress) models.ImportResult { func handleError(result models.ImportResult, err error, progress chan models.Progress) models.ImportResult {
result.Error = err result.Error = err
progress <- models.Progress{}.FromImportResult(result).Complete() progress <- models.Progress{}.FromImportResult(result).Abort()
return result return result
} }

View file

@ -77,14 +77,11 @@ func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error {
Title: config.GetString("title"), Title: config.GetString("title"),
Creator: config.GetString("username"), Creator: config.GetString("username"),
Identifier: config.GetString("identifier"), Identifier: config.GetString("identifier"),
Date: time.Now(),
Tracks: make([]jspf.Track, 0), Tracks: make([]jspf.Track, 0),
Extension: jspf.ExtensionMap{
jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{
LastModifiedAt: time.Now(),
Public: true,
},
},
} }
b.addMusicBrainzPlaylistExtension()
return nil return nil
} }
@ -97,11 +94,9 @@ func (b *JSPFBackend) FinishImport() error {
} }
func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
defer close(results)
err := b.readJSPF() err := b.readJSPF()
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -131,11 +126,9 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo
} }
func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
defer close(results)
err := b.readJSPF() err := b.readJSPF()
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
@ -331,6 +324,7 @@ func (b *JSPFBackend) readJSPF() error {
return err return err
} }
b.playlist = playlist.Playlist b.playlist = playlist.Playlist
b.addMusicBrainzPlaylistExtension()
} }
} }
@ -350,3 +344,13 @@ func (b *JSPFBackend) writeJSPF() error {
defer file.Close() defer file.Close()
return playlist.Write(file) return playlist.Write(file)
} }
func (b *JSPFBackend) addMusicBrainzPlaylistExtension() {
if b.playlist.Extension == nil {
b.playlist.Extension = make(jspf.ExtensionMap, 1)
}
extension := jspf.MusicBrainzPlaylistExtension{Public: true}
b.playlist.Extension.Get(jspf.MusicBrainzPlaylistExtensionID, &extension)
extension.LastModifiedAt = time.Now()
b.playlist.Extension[jspf.MusicBrainzPlaylistExtensionID] = extension
}

View file

@ -93,8 +93,6 @@ func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan
minTime := oldestTimestamp minTime := oldestTimestamp
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
p := models.Progress{Total: int64(page)} p := models.Progress{Total: int64(page)}
@ -110,7 +108,7 @@ out:
result, err := b.client.User.GetRecentTracks(args) result, err := b.client.User.GetRecentTracks(args)
if err != nil { if err != nil {
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
progress <- p.Complete() progress <- p.Abort()
return return
} }
@ -129,7 +127,7 @@ out:
timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64) timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64)
if err != nil { if err != nil {
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
progress <- p.Complete() progress <- p.Abort()
break out break out
} }
if timestamp > oldestTimestamp.Unix() { if timestamp > oldestTimestamp.Unix() {
@ -258,8 +256,6 @@ func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan m
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
loves := make(models.LovesList, 0, 2*MaxItemsPerGet) loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
p := models.Progress{Total: int64(perPage)} p := models.Progress{Total: int64(perPage)}
var totalCount int var totalCount int
@ -272,7 +268,7 @@ out:
"page": page, "page": page,
}) })
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
@ -286,7 +282,7 @@ out:
for _, track := range result.Tracks { for _, track := range result.Tracks {
timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64) timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }

View file

@ -81,14 +81,12 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
totalDuration := startTime.Sub(minTime) totalDuration := startTime.Sub(minTime)
defer close(results)
p := models.Progress{Total: int64(totalDuration.Seconds())} p := models.Progress{Total: int64(totalDuration.Seconds())}
for { for {
result, err := b.client.GetListens(b.username, time.Now(), minTime) result, err := b.client.GetListens(b.username, time.Now(), minTime)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -195,15 +193,15 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
} }
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
defer close(results)
exportChan := make(chan models.LovesResult) exportChan := make(chan models.LovesResult)
p := models.Progress{} p := models.Progress{}
go b.exportLoves(oldestTimestamp, exportChan) go b.exportLoves(oldestTimestamp, exportChan)
for existingLoves := range exportChan { for existingLoves := range exportChan {
if existingLoves.Error != nil { if existingLoves.Error != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.LovesResult{Error: existingLoves.Error} results <- models.LovesResult{Error: existingLoves.Error}
return
} }
p.Total = int64(existingLoves.Total) p.Total = int64(existingLoves.Total)

View file

@ -67,8 +67,6 @@ func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan
page := 0 page := 0
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
listens := make(models.ListensList, 0, 2*perPage) listens := make(models.ListensList, 0, 2*perPage)
p := models.Progress{Total: int64(perPage)} p := models.Progress{Total: int64(perPage)}
@ -107,6 +105,7 @@ out:
} }
func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
p := models.Progress{}.FromImportResult(importResult)
for _, listen := range export.Items { for _, listen := range export.Items {
scrobble := NewScrobble{ scrobble := NewScrobble{
Title: listen.TrackName, Title: listen.TrackName,
@ -127,7 +126,7 @@ func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResu
importResult.UpdateTimestamp(listen.ListenedAt) importResult.UpdateTimestamp(listen.ListenedAt)
importResult.ImportCount += 1 importResult.ImportCount += 1
progress <- models.Progress{}.FromImportResult(importResult) progress <- p.FromImportResult(importResult)
} }
return importResult, nil return importResult, nil

View file

@ -132,10 +132,9 @@ func (b *ScrobblerLogBackend) FinishImport() error {
} }
func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
defer close(results)
file, err := os.Open(b.filePath) file, err := os.Open(b.filePath)
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }

View file

@ -101,14 +101,12 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha
totalDuration := startTime.Sub(oldestTimestamp) totalDuration := startTime.Sub(oldestTimestamp)
defer close(results)
p := models.Progress{Total: int64(totalDuration.Seconds())} p := models.Progress{Total: int64(totalDuration.Seconds())}
for { for {
result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet) result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -120,7 +118,7 @@ func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results cha
// Set minTime to the newest returned listen // Set minTime to the newest returned listen
after, err := strconv.ParseInt(result.Cursors.After, 10, 64) after, err := strconv.ParseInt(result.Cursors.After, 10, 64)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} else if after <= minTime.Unix() { } else if after <= minTime.Unix() {
@ -163,8 +161,6 @@ func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan
offset := math.MaxInt32 offset := math.MaxInt32
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
p := models.Progress{Total: int64(perPage)} p := models.Progress{Total: int64(perPage)}
totalCount := 0 totalCount := 0
exportCount := 0 exportCount := 0
@ -173,7 +169,7 @@ out:
for { for {
result, err := b.client.UserTracks(offset, perPage) result, err := b.client.UserTracks(offset, perPage)
if err != nil { if err != nil {
progress <- p.Complete() progress <- p.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }

View file

@ -73,11 +73,9 @@ func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error {
} }
func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
defer close(results)
files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob)) files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob))
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -88,7 +86,7 @@ func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results
for i, filePath := range files { for i, filePath := range files {
history, err := readHistoryFile(filePath) history, err := readHistoryFile(filePath)
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }

View file

@ -64,17 +64,16 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
} }
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
defer close(results)
err := b.client.Authenticate(b.password) err := b.client.Authenticate(b.password)
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
starred, err := b.client.GetStarred2(map[string]string{}) starred, err := b.client.GetStarred2(map[string]string{})
if err != nil { if err != nil {
progress <- models.Progress{}.Complete() progress <- models.Progress{}.Abort()
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -28,7 +28,8 @@ import (
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
) )
func progressBar(wg *sync.WaitGroup, exportProgress chan models.Progress, importProgress chan models.Progress) *mpb.Progress { func progressBar(exportProgress chan models.Progress, importProgress chan models.Progress) *mpb.Progress {
wg := &sync.WaitGroup{}
p := mpb.New( p := mpb.New(
mpb.WithWaitGroup(wg), mpb.WithWaitGroup(wg),
mpb.WithOutput(color.Output), mpb.WithOutput(color.Output),
@ -58,10 +59,12 @@ func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar {
), ),
mpb.AppendDecorators( mpb.AppendDecorators(
decor.OnComplete( decor.OnComplete(
decor.OnAbort(
decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}), decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}),
i18n.Tr("aborted"),
),
i18n.Tr("done"), i18n.Tr("done"),
), ),
// decor.OnComplete(decor.Percentage(decor.WC{W: 5, C: decor.DSyncWidthR}), "done"),
decor.Name(" "), decor.Name(" "),
), ),
) )
@ -72,6 +75,10 @@ func updateProgressBar(bar *mpb.Bar, wg *sync.WaitGroup, progressChan chan model
defer wg.Done() defer wg.Done()
lastIterTime := time.Now() lastIterTime := time.Now()
for progress := range progressChan { for progress := range progressChan {
if progress.Aborted {
bar.Abort(false)
return
}
oldIterTime := lastIterTime oldIterTime := lastIterTime
lastIterTime = time.Now() lastIterTime = time.Now()
bar.EwmaSetCurrent(progress.Elapsed, lastIterTime.Sub(oldIterTime)) bar.EwmaSetCurrent(progress.Elapsed, lastIterTime.Sub(oldIterTime))

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the 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 terms of the GNU General Public License as published by the Free Software
@ -19,7 +19,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -112,8 +111,7 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
// Prepare progress bars // Prepare progress bars
exportProgress := make(chan models.Progress) exportProgress := make(chan models.Progress)
importProgress := make(chan models.Progress) importProgress := make(chan models.Progress)
var wg sync.WaitGroup progress := progressBar(exportProgress, importProgress)
progress := progressBar(&wg, exportProgress, importProgress)
// Export from source // Export from source
exportChan := make(chan R, 1000) exportChan := make(chan R, 1000)
@ -123,23 +121,20 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
resultChan := make(chan models.ImportResult) resultChan := make(chan models.ImportResult)
go imp.Process(exportChan, resultChan, importProgress) go imp.Process(exportChan, resultChan, importProgress)
result := <-resultChan result := <-resultChan
if timestamp.After(result.LastTimestamp) {
result.LastTimestamp = timestamp
}
wg.Wait()
progress.Wait() progress.Wait()
// Update timestamp
err = c.updateTimestamp(&result, timestamp)
if err != nil {
return err
}
fmt.Println(i18n.Tr("Imported %v of %v %s into %v.",
result.ImportCount, result.TotalCount, c.entity, c.targetName))
if result.Error != nil { if result.Error != nil {
printTimestamp("Import failed, last reported timestamp was %v (%s)", result.LastTimestamp) printTimestamp("Import failed, last reported timestamp was %v (%s)", result.LastTimestamp)
return result.Error return result.Error
} }
fmt.Println(i18n.Tr("Imported %v of %v %s into %v.",
result.ImportCount, result.TotalCount, c.entity, c.targetName))
// Update timestamp
err = c.updateTimestamp(result, timestamp)
if err != nil {
return err
}
// Print errors // Print errors
if len(result.ImportLog) > 0 { if len(result.ImportLog) > 0 {
@ -179,7 +174,7 @@ func (c *TransferCmd[E, I, R]) timestamp() (time.Time, error) {
return time.Time{}, errors.New(i18n.Tr("invalid timestamp string \"%v\"", flagValue)) return time.Time{}, errors.New(i18n.Tr("invalid timestamp string \"%v\"", flagValue))
} }
func (c *TransferCmd[E, I, R]) updateTimestamp(result models.ImportResult, oldTimestamp time.Time) error { func (c *TransferCmd[E, I, R]) updateTimestamp(result *models.ImportResult, oldTimestamp time.Time) error {
if oldTimestamp.After(result.LastTimestamp) { if oldTimestamp.After(result.LastTimestamp) {
result.LastTimestamp = oldTimestamp result.LastTimestamp = oldTimestamp
} }

View file

@ -213,6 +213,7 @@ type Progress struct {
Total int64 Total int64
Elapsed int64 Elapsed int64
Completed bool Completed bool
Aborted bool
} }
func (p Progress) FromImportResult(result ImportResult) Progress { func (p Progress) FromImportResult(result ImportResult) Progress {
@ -226,3 +227,8 @@ func (p Progress) Complete() Progress {
p.Completed = true p.Completed = true
return p return p
} }
func (p Progress) Abort() Progress {
p.Aborted = true
return p
}

View file

@ -42,12 +42,12 @@ var messageKeyToIndex = map[string]int{
"\tbackend: %v": 11, "\tbackend: %v": 11,
"\texport: %s": 0, "\texport: %s": 0,
"\timport: %s\n": 1, "\timport: %s\n": 1,
"%v: %v": 48, "%v: %v": 49,
"Aborted": 8, "Aborted": 8,
"Access token": 19, "Access token": 19,
"Access token received, you can use %v now.\n": 34, "Access token received, you can use %v now.\n": 34,
"Append to file": 21, "Append to file": 21,
"Backend": 42, "Backend": 43,
"Check for duplicate listens on import (slower)": 24, "Check for duplicate listens on import (slower)": 24,
"Client ID": 15, "Client ID": 15,
"Client secret": 16, "Client secret": 16,
@ -57,45 +57,46 @@ var messageKeyToIndex = map[string]int{
"Error: OAuth state mismatch": 33, "Error: OAuth state mismatch": 33,
"Failed reading config: %v": 2, "Failed reading config: %v": 2,
"File path": 20, "File path": 20,
"From timestamp: %v (%v)": 44, "From timestamp: %v (%v)": 45,
"Ignore listens in incognito mode": 30, "Ignore listens in incognito mode": 30,
"Ignore skipped listens": 27, "Ignore skipped listens": 27,
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 25, "Ignored duplicate listen %v: \"%v\" by %v (%v)": 25,
"Import failed, last reported timestamp was %v (%s)": 45, "Import failed, last reported timestamp was %v (%s)": 47,
"Import log:": 47, "Import log:": 48,
"Imported %v of %v %s into %v.": 46, "Imported %v of %v %s into %v.": 46,
"Latest timestamp: %v (%v)": 50, "Latest timestamp: %v (%v)": 51,
"Minimum playback duration for skipped tracks (seconds)": 31, "Minimum playback duration for skipped tracks (seconds)": 31,
"No": 39, "No": 40,
"Playlist title": 22, "Playlist title": 22,
"Saved service %v using backend %v": 5, "Saved service %v using backend %v": 5,
"Server URL": 17, "Server URL": 17,
"Service": 41, "Service": 42,
"Service \"%v\" deleted\n": 9, "Service \"%v\" deleted\n": 9,
"Service name": 3, "Service name": 3,
"Specify a time zone for the listen timestamps": 28, "Specify a time zone for the listen timestamps": 28,
"The backend %v requires authentication. Authenticate now?": 6, "The backend %v requires authentication. Authenticate now?": 6,
"Token received, you can close this window now.": 12, "Token received, you can close this window now.": 12,
"Transferring %s from %s to %s…": 43, "Transferring %s from %s to %s…": 44,
"Unique playlist identifier": 23, "Unique playlist identifier": 23,
"Updated service %v using backend %v\n": 10, "Updated service %v using backend %v\n": 10,
"User name": 18, "User name": 18,
"Visit the URL for authorization: %v": 32, "Visit the URL for authorization: %v": 32,
"Yes": 38, "Yes": 39,
"a service with this name already exists": 4, "a service with this name already exists": 4,
"aborted": 37,
"backend %s does not implement %s": 13, "backend %s does not implement %s": 13,
"done": 37, "done": 38,
"exporting": 35, "exporting": 35,
"importing": 36, "importing": 36,
"invalid timestamp string \"%v\"": 49, "invalid timestamp string \"%v\"": 50,
"key must only consist of A-Za-z0-9_-": 52, "key must only consist of A-Za-z0-9_-": 53,
"no configuration file defined, cannot write config": 51, "no configuration file defined, cannot write config": 52,
"no existing service configurations": 40, "no existing service configurations": 41,
"no service configuration \"%v\"": 53, "no service configuration \"%v\"": 54,
"unknown backend \"%s\"": 14, "unknown backend \"%s\"": 14,
} }
var deIndex = []uint32{ // 55 elements var deIndex = []uint32{ // 56 elements
// Entry 0 - 1F // Entry 0 - 1F
0x00000000, 0x00000013, 0x00000027, 0x00000052, 0x00000000, 0x00000013, 0x00000027, 0x00000052,
0x0000005e, 0x0000008d, 0x000000bd, 0x00000104, 0x0000005e, 0x0000008d, 0x000000bd, 0x00000104,
@ -107,14 +108,14 @@ var deIndex = []uint32{ // 55 elements
0x0000037e, 0x000003a4, 0x000003b4, 0x000003da, 0x0000037e, 0x000003a4, 0x000003b4, 0x000003da,
// Entry 20 - 3F // Entry 20 - 3F
0x00000418, 0x00000443, 0x0000046d, 0x000004ad, 0x00000418, 0x00000443, 0x0000046d, 0x000004ad,
0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd, 0x000004b8, 0x000004c3, 0x000004cf, 0x000004d6,
0x000004d2, 0x000004fb, 0x00000503, 0x0000050b, 0x000004d9, 0x000004de, 0x00000507, 0x0000050f,
0x00000534, 0x00000552, 0x0000058f, 0x000005ba, 0x00000517, 0x00000540, 0x0000055e, 0x00000589,
0x000005c5, 0x000005d2, 0x000005f6, 0x00000619, 0x000005c6, 0x000005d1, 0x000005de, 0x00000602,
0x0000066a, 0x000006a1, 0x000006c8, 0x00000625, 0x00000676, 0x000006ad, 0x000006d4,
} // Size: 244 bytes } // Size: 248 bytes
const deData string = "" + // Size: 1736 bytes const deData string = "" + // Size: 1748 bytes
"\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" + "\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" +
" %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" + " %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" +
"in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" + "in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" +
@ -134,17 +135,17 @@ const deData string = "" + // Size: 1736 bytes
"inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" + "inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" +
"ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" + "ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" +
"rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" + "rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" +
"det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" + "det werden.\x02exportiere\x02importiere\x02abgebrochen\x02fertig\x02Ja" +
" bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" + "\x02Nein\x02keine bestehenden Servicekonfigurationen\x02Service\x02Backe" +
"[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fe" + "nd\x02Übertrage %[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[" +
"hlgeschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %" + "2]v)\x02%[1]v von %[2]v %[3]s in %[4]v importiert.\x02Import fehlgeschla" +
"[3]s in %[4]v importiert.\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" + "gen, letzter Zeitstempel war %[1]v (%[2]s)\x02Importlog:\x02%[1]v: %[2]v" +
"itstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfigu" + "\x02ungültiger Zeitstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)" +
"rationsdatei definiert, Konfiguration kann nicht geschrieben werden\x02S" + "\x02keine Konfigurationsdatei definiert, Konfiguration kann nicht geschr" +
"chlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekon" + "ieben werden\x02Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten" +
"figuration „%[1]v“" "\x02keine Servicekonfiguration „%[1]v“"
var enIndex = []uint32{ // 55 elements var enIndex = []uint32{ // 56 elements
// Entry 0 - 1F // Entry 0 - 1F
0x00000000, 0x00000013, 0x00000027, 0x00000044, 0x00000000, 0x00000013, 0x00000027, 0x00000044,
0x00000051, 0x00000079, 0x000000a1, 0x000000de, 0x00000051, 0x00000079, 0x000000a1, 0x000000de,
@ -156,14 +157,14 @@ var enIndex = []uint32{ // 55 elements
0x00000307, 0x00000335, 0x00000344, 0x00000365, 0x00000307, 0x00000335, 0x00000344, 0x00000365,
// Entry 20 - 3F // Entry 20 - 3F
0x0000039c, 0x000003c3, 0x000003df, 0x00000412, 0x0000039c, 0x000003c3, 0x000003df, 0x00000412,
0x0000041c, 0x00000426, 0x0000042b, 0x0000042f, 0x0000041c, 0x00000426, 0x0000042e, 0x00000433,
0x00000432, 0x00000455, 0x0000045d, 0x00000465, 0x00000437, 0x0000043a, 0x0000045d, 0x00000465,
0x0000048f, 0x000004ad, 0x000004e6, 0x00000510, 0x0000046d, 0x00000497, 0x000004b5, 0x000004df,
0x0000051c, 0x00000529, 0x0000054a, 0x0000056a, 0x00000518, 0x00000524, 0x00000531, 0x00000552,
0x0000059d, 0x000005c2, 0x000005e3, 0x00000572, 0x000005a5, 0x000005ca, 0x000005eb,
} // Size: 244 bytes } // Size: 248 bytes
const enData string = "" + // Size: 1507 bytes const enData string = "" + // Size: 1515 bytes
"\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" + "\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" +
" %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" + " %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" +
" this name already exists\x02Saved service %[1]v using backend %[2]v\x02" + " this name already exists\x02Saved service %[1]v using backend %[2]v\x02" +
@ -181,13 +182,14 @@ const enData string = "" + // Size: 1507 bytes
"mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" + "mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" +
"yback duration for skipped tracks (seconds)\x02Visit the URL for authori" + "yback duration for skipped tracks (seconds)\x02Visit the URL for authori" +
"zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " + "zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " +
"token received, you can use %[1]v now.\x02exporting\x02importing\x02done" + "token received, you can use %[1]v now.\x02exporting\x02importing\x02abor" +
"\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" + "ted\x02done\x02Yes\x02No\x02no existing service configurations\x02Servic" +
"d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" + "e\x02Backend\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestam" +
"[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" + "p: %[1]v (%[2]v)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Import " +
"orted %[1]v of %[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v" + "failed, last reported timestamp was %[1]v (%[2]s)\x02Import log:\x02%[1]" +
"\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" + "v: %[2]v\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: " +
"[2]v)\x02no configuration file defined, cannot write config\x02key must " + "%[1]v (%[2]v)\x02no configuration file defined, cannot write config\x02k" +
"only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22" "ey must only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]" +
"v\x22"
// Total table size 3731 bytes (3KiB); checksum: F7951710 // Total table size 3759 bytes (3KiB); checksum: 7B4CF967

View file

@ -368,21 +368,23 @@
"id": "exporting", "id": "exporting",
"message": "exporting", "message": "exporting",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true,
"translation": "exportiere" "translation": "exportiere"
}, },
{ {
"id": "importing", "id": "importing",
"message": "importing", "message": "importing",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true,
"translation": "importiere" "translation": "importiere"
}, },
{
"id": "aborted",
"message": "aborted",
"translation": "abgebrochen"
},
{ {
"id": "done", "id": "done",
"message": "done", "message": "done",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true,
"translation": "fertig" "translation": "fertig"
}, },
{ {
@ -462,27 +464,6 @@
} }
] ]
}, },
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
]
},
{ {
"id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.",
"message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.",
@ -522,6 +503,27 @@
} }
] ]
}, },
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
]
},
{ {
"id": "Import log:", "id": "Import log:",
"message": "Import log:", "message": "Import log:",

View file

@ -368,22 +368,24 @@
"id": "exporting", "id": "exporting",
"message": "exporting", "message": "exporting",
"translation": "exportiere", "translation": "exportiere",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "importing", "id": "importing",
"message": "importing", "message": "importing",
"translation": "importiere", "translation": "importiere",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true },
{
"id": "aborted",
"message": "aborted",
"translation": "abgebrochen"
}, },
{ {
"id": "done", "id": "done",
"message": "done", "message": "done",
"translation": "fertig", "translation": "fertig",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Yes", "id": "Yes",
@ -462,27 +464,6 @@
} }
] ]
}, },
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
]
},
{ {
"id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.",
"message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.",
@ -522,6 +503,27 @@
} }
] ]
}, },
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
]
},
{ {
"id": "Import log:", "id": "Import log:",
"message": "Import log:", "message": "Import log:",

View file

@ -15,8 +15,7 @@
"argNum": 1, "argNum": 1,
"expr": "strings.Join(info.ExportCapabilities, \", \")" "expr": "strings.Join(info.ExportCapabilities, \", \")"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "import: {ImportCapabilities__}", "id": "import: {ImportCapabilities__}",
@ -32,8 +31,7 @@
"argNum": 1, "argNum": 1,
"expr": "strings.Join(info.ImportCapabilities, \", \")" "expr": "strings.Join(info.ImportCapabilities, \", \")"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Failed reading config: {Err}", "id": "Failed reading config: {Err}",
@ -49,22 +47,19 @@
"argNum": 1, "argNum": 1,
"expr": "err" "expr": "err"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Service name", "id": "Service name",
"message": "Service name", "message": "Service name",
"translation": "Service name", "translation": "Service name",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "a service with this name already exists", "id": "a service with this name already exists",
"message": "a service with this name already exists", "message": "a service with this name already exists",
"translation": "a service with this name already exists", "translation": "a service with this name already exists",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Saved service {Name} using backend {Backend}", "id": "Saved service {Name} using backend {Backend}",
@ -88,8 +83,7 @@
"argNum": 2, "argNum": 2,
"expr": "service.Backend" "expr": "service.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "The backend {Backend} requires authentication. Authenticate now?", "id": "The backend {Backend} requires authentication. Authenticate now?",
@ -105,8 +99,7 @@
"argNum": 1, "argNum": 1,
"expr": "service.Backend" "expr": "service.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Delete the service configuration \"{Service}\"?", "id": "Delete the service configuration \"{Service}\"?",
@ -122,15 +115,13 @@
"argNum": 1, "argNum": 1,
"expr": "service" "expr": "service"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Aborted", "id": "Aborted",
"message": "Aborted", "message": "Aborted",
"translation": "Aborted", "translation": "Aborted",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Service \"{Name}\" deleted", "id": "Service \"{Name}\" deleted",
@ -146,8 +137,7 @@
"argNum": 1, "argNum": 1,
"expr": "service.Name" "expr": "service.Name"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Updated service {Name} using backend {Backend}", "id": "Updated service {Name} using backend {Backend}",
@ -171,8 +161,7 @@
"argNum": 2, "argNum": 2,
"expr": "service.Backend" "expr": "service.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "backend: {Backend}", "id": "backend: {Backend}",
@ -188,15 +177,13 @@
"argNum": 1, "argNum": 1,
"expr": "s.Backend" "expr": "s.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Token received, you can close this window now.", "id": "Token received, you can close this window now.",
"message": "Token received, you can close this window now.", "message": "Token received, you can close this window now.",
"translation": "Token received, you can close this window now.", "translation": "Token received, you can close this window now.",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "backend {Backend} does not implement {InterfaceName}", "id": "backend {Backend} does not implement {InterfaceName}",
@ -220,8 +207,7 @@
"argNum": 2, "argNum": 2,
"expr": "interfaceName" "expr": "interfaceName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "unknown backend \"{BackendName}\"", "id": "unknown backend \"{BackendName}\"",
@ -237,78 +223,67 @@
"argNum": 1, "argNum": 1,
"expr": "backendName" "expr": "backendName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Client ID", "id": "Client ID",
"message": "Client ID", "message": "Client ID",
"translation": "Client ID", "translation": "Client ID",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Client secret", "id": "Client secret",
"message": "Client secret", "message": "Client secret",
"translation": "Client secret", "translation": "Client secret",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Server URL", "id": "Server URL",
"message": "Server URL", "message": "Server URL",
"translation": "Server URL", "translation": "Server URL",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "User name", "id": "User name",
"message": "User name", "message": "User name",
"translation": "User name", "translation": "User name",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Access token", "id": "Access token",
"message": "Access token", "message": "Access token",
"translation": "Access token", "translation": "Access token",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "File path", "id": "File path",
"message": "File path", "message": "File path",
"translation": "File path", "translation": "File path",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Append to file", "id": "Append to file",
"message": "Append to file", "message": "Append to file",
"translation": "Append to file", "translation": "Append to file",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Playlist title", "id": "Playlist title",
"message": "Playlist title", "message": "Playlist title",
"translation": "Playlist title", "translation": "Playlist title",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Unique playlist identifier", "id": "Unique playlist identifier",
"message": "Unique playlist identifier", "message": "Unique playlist identifier",
"translation": "Unique playlist identifier", "translation": "Unique playlist identifier",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Check for duplicate listens on import (slower)", "id": "Check for duplicate listens on import (slower)",
"message": "Check for duplicate listens on import (slower)", "message": "Check for duplicate listens on import (slower)",
"translation": "Check for duplicate listens on import (slower)", "translation": "Check for duplicate listens on import (slower)",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
@ -348,50 +323,43 @@
"argNum": 4, "argNum": 4,
"expr": "l.RecordingMBID" "expr": "l.RecordingMBID"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Disable auto correction of submitted listens", "id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens", "message": "Disable auto correction of submitted listens",
"translation": "Disable auto correction of submitted listens", "translation": "Disable auto correction of submitted listens",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Ignore skipped listens", "id": "Ignore skipped listens",
"message": "Ignore skipped listens", "message": "Ignore skipped listens",
"translation": "Ignore skipped listens", "translation": "Ignore skipped listens",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Specify a time zone for the listen timestamps", "id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps", "message": "Specify a time zone for the listen timestamps",
"translation": "Specify a time zone for the listen timestamps", "translation": "Specify a time zone for the listen timestamps",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Directory path", "id": "Directory path",
"message": "Directory path", "message": "Directory path",
"translation": "Directory path", "translation": "Directory path",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Ignore listens in incognito mode", "id": "Ignore listens in incognito mode",
"message": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode",
"translation": "Ignore listens in incognito mode", "translation": "Ignore listens in incognito mode",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Minimum playback duration for skipped tracks (seconds)", "id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)",
"translation": "Minimum playback duration for skipped tracks (seconds)", "translation": "Minimum playback duration for skipped tracks (seconds)",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Visit the URL for authorization: {URL}", "id": "Visit the URL for authorization: {URL}",
@ -407,15 +375,13 @@
"argNum": 1, "argNum": 1,
"expr": "authURL.URL" "expr": "authURL.URL"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Error: OAuth state mismatch", "id": "Error: OAuth state mismatch",
"message": "Error: OAuth state mismatch", "message": "Error: OAuth state mismatch",
"translation": "Error: OAuth state mismatch", "translation": "Error: OAuth state mismatch",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Access token received, you can use {Name} now.", "id": "Access token received, you can use {Name} now.",
@ -431,64 +397,55 @@
"argNum": 1, "argNum": 1,
"expr": "service.Name" "expr": "service.Name"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "exporting", "id": "exporting",
"message": "exporting", "message": "exporting",
"translation": "exporting", "translation": "exporting",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "importing", "id": "importing",
"message": "importing", "message": "importing",
"translation": "importing", "translation": "importing",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "done", "id": "done",
"message": "done", "message": "done",
"translation": "done", "translation": "done",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Yes", "id": "Yes",
"message": "Yes", "message": "Yes",
"translation": "Yes", "translation": "Yes",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "No", "id": "No",
"message": "No", "message": "No",
"translation": "No", "translation": "No",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "no existing service configurations", "id": "no existing service configurations",
"message": "no existing service configurations", "message": "no existing service configurations",
"translation": "no existing service configurations", "translation": "no existing service configurations",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Service", "id": "Service",
"message": "Service", "message": "Service",
"translation": "Service", "translation": "Service",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Backend", "id": "Backend",
"message": "Backend", "message": "Backend",
"translation": "Backend", "translation": "Backend",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Transferring {Entity} from {SourceName} to {TargetName}…", "id": "Transferring {Entity} from {SourceName} to {TargetName}…",
@ -520,8 +477,7 @@
"argNum": 3, "argNum": 3,
"expr": "c.targetName" "expr": "c.targetName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "From timestamp: {Arg_1} ({Arg_2})", "id": "From timestamp: {Arg_1} ({Arg_2})",
@ -543,8 +499,7 @@
"underlyingType": "interface{}", "underlyingType": "interface{}",
"argNum": 2 "argNum": 2
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
@ -566,8 +521,7 @@
"underlyingType": "string", "underlyingType": "string",
"argNum": 2 "argNum": 2
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.",
@ -607,15 +561,13 @@
"argNum": 4, "argNum": 4,
"expr": "c.targetName" "expr": "c.targetName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Import log:", "id": "Import log:",
"message": "Import log:", "message": "Import log:",
"translation": "Import log:", "translation": "Import log:",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "{Type}: {Message}", "id": "{Type}: {Message}",
@ -639,8 +591,7 @@
"argNum": 2, "argNum": 2,
"expr": "entry.Message" "expr": "entry.Message"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "invalid timestamp string \"{FlagValue}\"", "id": "invalid timestamp string \"{FlagValue}\"",
@ -656,8 +607,7 @@
"argNum": 1, "argNum": 1,
"expr": "flagValue" "expr": "flagValue"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Latest timestamp: {Arg_1} ({Arg_2})", "id": "Latest timestamp: {Arg_1} ({Arg_2})",
@ -679,22 +629,19 @@
"underlyingType": "interface{}", "underlyingType": "interface{}",
"argNum": 2 "argNum": 2
} }
], ]
"fuzzy": true
}, },
{ {
"id": "no configuration file defined, cannot write config", "id": "no configuration file defined, cannot write config",
"message": "no configuration file defined, cannot write config", "message": "no configuration file defined, cannot write config",
"translation": "no configuration file defined, cannot write config", "translation": "no configuration file defined, cannot write config",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "key must only consist of A-Za-z0-9_-", "id": "key must only consist of A-Za-z0-9_-",
"message": "key must only consist of A-Za-z0-9_-", "message": "key must only consist of A-Za-z0-9_-",
"translation": "key must only consist of A-Za-z0-9_-", "translation": "key must only consist of A-Za-z0-9_-",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "no service configuration \"{Name}\"", "id": "no service configuration \"{Name}\"",
@ -710,8 +657,7 @@
"argNum": 1, "argNum": 1,
"expr": "name" "expr": "name"
} }
], ]
"fuzzy": true
} }
] ]
} }

View file

@ -15,8 +15,7 @@
"argNum": 1, "argNum": 1,
"expr": "strings.Join(info.ExportCapabilities, \", \")" "expr": "strings.Join(info.ExportCapabilities, \", \")"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "import: {ImportCapabilities__}", "id": "import: {ImportCapabilities__}",
@ -32,8 +31,7 @@
"argNum": 1, "argNum": 1,
"expr": "strings.Join(info.ImportCapabilities, \", \")" "expr": "strings.Join(info.ImportCapabilities, \", \")"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Failed reading config: {Err}", "id": "Failed reading config: {Err}",
@ -49,22 +47,19 @@
"argNum": 1, "argNum": 1,
"expr": "err" "expr": "err"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Service name", "id": "Service name",
"message": "Service name", "message": "Service name",
"translation": "Service name", "translation": "Service name",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "a service with this name already exists", "id": "a service with this name already exists",
"message": "a service with this name already exists", "message": "a service with this name already exists",
"translation": "a service with this name already exists", "translation": "a service with this name already exists",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Saved service {Name} using backend {Backend}", "id": "Saved service {Name} using backend {Backend}",
@ -88,8 +83,7 @@
"argNum": 2, "argNum": 2,
"expr": "service.Backend" "expr": "service.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "The backend {Backend} requires authentication. Authenticate now?", "id": "The backend {Backend} requires authentication. Authenticate now?",
@ -105,8 +99,7 @@
"argNum": 1, "argNum": 1,
"expr": "service.Backend" "expr": "service.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Delete the service configuration \"{Service}\"?", "id": "Delete the service configuration \"{Service}\"?",
@ -122,15 +115,13 @@
"argNum": 1, "argNum": 1,
"expr": "service" "expr": "service"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Aborted", "id": "Aborted",
"message": "Aborted", "message": "Aborted",
"translation": "Aborted", "translation": "Aborted",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Service \"{Name}\" deleted", "id": "Service \"{Name}\" deleted",
@ -146,8 +137,7 @@
"argNum": 1, "argNum": 1,
"expr": "service.Name" "expr": "service.Name"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Updated service {Name} using backend {Backend}", "id": "Updated service {Name} using backend {Backend}",
@ -171,8 +161,7 @@
"argNum": 2, "argNum": 2,
"expr": "service.Backend" "expr": "service.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "backend: {Backend}", "id": "backend: {Backend}",
@ -188,15 +177,13 @@
"argNum": 1, "argNum": 1,
"expr": "s.Backend" "expr": "s.Backend"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Token received, you can close this window now.", "id": "Token received, you can close this window now.",
"message": "Token received, you can close this window now.", "message": "Token received, you can close this window now.",
"translation": "Token received, you can close this window now.", "translation": "Token received, you can close this window now.",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "backend {Backend} does not implement {InterfaceName}", "id": "backend {Backend} does not implement {InterfaceName}",
@ -220,8 +207,7 @@
"argNum": 2, "argNum": 2,
"expr": "interfaceName" "expr": "interfaceName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "unknown backend \"{BackendName}\"", "id": "unknown backend \"{BackendName}\"",
@ -237,78 +223,67 @@
"argNum": 1, "argNum": 1,
"expr": "backendName" "expr": "backendName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Client ID", "id": "Client ID",
"message": "Client ID", "message": "Client ID",
"translation": "Client ID", "translation": "Client ID",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Client secret", "id": "Client secret",
"message": "Client secret", "message": "Client secret",
"translation": "Client secret", "translation": "Client secret",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Server URL", "id": "Server URL",
"message": "Server URL", "message": "Server URL",
"translation": "Server URL", "translation": "Server URL",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "User name", "id": "User name",
"message": "User name", "message": "User name",
"translation": "User name", "translation": "User name",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Access token", "id": "Access token",
"message": "Access token", "message": "Access token",
"translation": "Access token", "translation": "Access token",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "File path", "id": "File path",
"message": "File path", "message": "File path",
"translation": "File path", "translation": "File path",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Append to file", "id": "Append to file",
"message": "Append to file", "message": "Append to file",
"translation": "Append to file", "translation": "Append to file",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Playlist title", "id": "Playlist title",
"message": "Playlist title", "message": "Playlist title",
"translation": "Playlist title", "translation": "Playlist title",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Unique playlist identifier", "id": "Unique playlist identifier",
"message": "Unique playlist identifier", "message": "Unique playlist identifier",
"translation": "Unique playlist identifier", "translation": "Unique playlist identifier",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Check for duplicate listens on import (slower)", "id": "Check for duplicate listens on import (slower)",
"message": "Check for duplicate listens on import (slower)", "message": "Check for duplicate listens on import (slower)",
"translation": "Check for duplicate listens on import (slower)", "translation": "Check for duplicate listens on import (slower)",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
@ -348,50 +323,43 @@
"argNum": 4, "argNum": 4,
"expr": "l.RecordingMBID" "expr": "l.RecordingMBID"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Disable auto correction of submitted listens", "id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens", "message": "Disable auto correction of submitted listens",
"translation": "Disable auto correction of submitted listens", "translation": "Disable auto correction of submitted listens",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Ignore skipped listens", "id": "Ignore skipped listens",
"message": "Ignore skipped listens", "message": "Ignore skipped listens",
"translation": "Ignore skipped listens", "translation": "Ignore skipped listens",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Specify a time zone for the listen timestamps", "id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps", "message": "Specify a time zone for the listen timestamps",
"translation": "Specify a time zone for the listen timestamps", "translation": "Specify a time zone for the listen timestamps",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Directory path", "id": "Directory path",
"message": "Directory path", "message": "Directory path",
"translation": "Directory path", "translation": "Directory path",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Ignore listens in incognito mode", "id": "Ignore listens in incognito mode",
"message": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode",
"translation": "Ignore listens in incognito mode", "translation": "Ignore listens in incognito mode",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Minimum playback duration for skipped tracks (seconds)", "id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)",
"translation": "Minimum playback duration for skipped tracks (seconds)", "translation": "Minimum playback duration for skipped tracks (seconds)",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Visit the URL for authorization: {URL}", "id": "Visit the URL for authorization: {URL}",
@ -407,15 +375,13 @@
"argNum": 1, "argNum": 1,
"expr": "authURL.URL" "expr": "authURL.URL"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Error: OAuth state mismatch", "id": "Error: OAuth state mismatch",
"message": "Error: OAuth state mismatch", "message": "Error: OAuth state mismatch",
"translation": "Error: OAuth state mismatch", "translation": "Error: OAuth state mismatch",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Access token received, you can use {Name} now.", "id": "Access token received, you can use {Name} now.",
@ -431,20 +397,24 @@
"argNum": 1, "argNum": 1,
"expr": "service.Name" "expr": "service.Name"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "exporting", "id": "exporting",
"message": "exporting", "message": "exporting",
"translation": "exporting", "translation": "exporting",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "importing", "id": "importing",
"message": "importing", "message": "importing",
"translation": "importing", "translation": "importing",
"translatorComment": "Copied from source."
},
{
"id": "aborted",
"message": "aborted",
"translation": "aborted",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true "fuzzy": true
}, },
@ -452,43 +422,37 @@
"id": "done", "id": "done",
"message": "done", "message": "done",
"translation": "done", "translation": "done",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Yes", "id": "Yes",
"message": "Yes", "message": "Yes",
"translation": "Yes", "translation": "Yes",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "No", "id": "No",
"message": "No", "message": "No",
"translation": "No", "translation": "No",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "no existing service configurations", "id": "no existing service configurations",
"message": "no existing service configurations", "message": "no existing service configurations",
"translation": "no existing service configurations", "translation": "no existing service configurations",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Service", "id": "Service",
"message": "Service", "message": "Service",
"translation": "Service", "translation": "Service",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Backend", "id": "Backend",
"message": "Backend", "message": "Backend",
"translation": "Backend", "translation": "Backend",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "Transferring {Entity} from {SourceName} to {TargetName}…", "id": "Transferring {Entity} from {SourceName} to {TargetName}…",
@ -520,8 +484,7 @@
"argNum": 3, "argNum": 3,
"expr": "c.targetName" "expr": "c.targetName"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "From timestamp: {Arg_1} ({Arg_2})", "id": "From timestamp: {Arg_1} ({Arg_2})",
@ -543,31 +506,7 @@
"underlyingType": "interface{}", "underlyingType": "interface{}",
"argNum": 2 "argNum": 2
} }
], ]
"fuzzy": true
},
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
],
"fuzzy": true
}, },
{ {
"id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.",
@ -607,15 +546,35 @@
"argNum": 4, "argNum": 4,
"expr": "c.targetName" "expr": "c.targetName"
} }
], ]
"fuzzy": true },
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
]
}, },
{ {
"id": "Import log:", "id": "Import log:",
"message": "Import log:", "message": "Import log:",
"translation": "Import log:", "translation": "Import log:",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "{Type}: {Message}", "id": "{Type}: {Message}",
@ -639,8 +598,7 @@
"argNum": 2, "argNum": 2,
"expr": "entry.Message" "expr": "entry.Message"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "invalid timestamp string \"{FlagValue}\"", "id": "invalid timestamp string \"{FlagValue}\"",
@ -656,8 +614,7 @@
"argNum": 1, "argNum": 1,
"expr": "flagValue" "expr": "flagValue"
} }
], ]
"fuzzy": true
}, },
{ {
"id": "Latest timestamp: {Arg_1} ({Arg_2})", "id": "Latest timestamp: {Arg_1} ({Arg_2})",
@ -679,22 +636,19 @@
"underlyingType": "interface{}", "underlyingType": "interface{}",
"argNum": 2 "argNum": 2
} }
], ]
"fuzzy": true
}, },
{ {
"id": "no configuration file defined, cannot write config", "id": "no configuration file defined, cannot write config",
"message": "no configuration file defined, cannot write config", "message": "no configuration file defined, cannot write config",
"translation": "no configuration file defined, cannot write config", "translation": "no configuration file defined, cannot write config",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "key must only consist of A-Za-z0-9_-", "id": "key must only consist of A-Za-z0-9_-",
"message": "key must only consist of A-Za-z0-9_-", "message": "key must only consist of A-Za-z0-9_-",
"translation": "key must only consist of A-Za-z0-9_-", "translation": "key must only consist of A-Za-z0-9_-",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source."
"fuzzy": true
}, },
{ {
"id": "no service configuration \"{Name}\"", "id": "no service configuration \"{Name}\"",
@ -710,8 +664,7 @@
"argNum": 1, "argNum": 1,
"expr": "name" "expr": "name"
} }
], ]
"fuzzy": true
} }
] ]
} }